feat: session file registry with drag-and-drop on day view
Adds a FileDropZone at the bottom of the day view where users can drop all session files at once. Filenames are regex-matched to subjects using their animal_id_string (word-boundary aware, so "2852" won't match "28520"). Only metadata is recorded — files are never uploaded. Backend: new session_files table (id, daily_status_id, filename, file_size BIGINT, file_type, last_modified, notes). Routes: POST /api/daily-statuses/:id/files (batch create) GET /api/daily-statuses/:id/files DELETE /api/session-files/:id Frontend: FileDropZone shows per-subject match groups, unmatched files, and a warning for subjects with no match. After saving, registered files are displayed with delete capability. test-files/: generate_test_files.sh creates 4 dummy files per subject (2×bin, 2×csv) with correct naming for drag-and-drop testing. Pre-generated for 2026-04-23 with IDs 2852/3076/3077/3078. Tests: 19 tests covering drag state, regex matching, save payload, partial-overlap prevention, multi-subject batching, and delete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ const animalsRouter = require('./routes/animals');
|
||||
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
||||
const auditLogsRouter = require('./routes/auditLogs');
|
||||
const analysesRouter = require('./routes/analyses');
|
||||
const sessionFilesRouter = require('./routes/sessionFiles');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -63,6 +64,7 @@ app.use('/api/animals', animalsRouter);
|
||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||
app.use('/api/audit-logs', auditLogsRouter);
|
||||
app.use('/api', analysesRouter);
|
||||
app.use('/api', sessionFilesRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
const router = require('express').Router();
|
||||
const prisma = require('../lib/prisma');
|
||||
|
||||
// POST /api/daily-statuses/:id/files — register file metadata (batch)
|
||||
// Body: [{ filename, file_size, file_type, last_modified, notes }, ...]
|
||||
router.post('/daily-statuses/:id/files', async (req, res, next) => {
|
||||
try {
|
||||
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
|
||||
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
||||
|
||||
const files = req.body;
|
||||
if (!Array.isArray(files) || files.length === 0)
|
||||
return res.status(422).json({ error: 'Request body must be a non-empty array of file metadata' });
|
||||
|
||||
if (files.length > 200)
|
||||
return res.status(422).json({ error: 'At most 200 files per request' });
|
||||
|
||||
for (const f of files) {
|
||||
if (!f.filename || typeof f.filename !== 'string' || !f.filename.trim())
|
||||
return res.status(422).json({ error: 'Each file must have a non-empty filename' });
|
||||
if (f.file_size == null || typeof f.file_size !== 'number' || f.file_size < 0)
|
||||
return res.status(422).json({ error: `file_size must be a non-negative number (got: ${f.file_size})` });
|
||||
}
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
files.map((f) =>
|
||||
prisma.sessionFile.create({
|
||||
data: {
|
||||
daily_status_id: req.params.id,
|
||||
filename: f.filename.trim(),
|
||||
file_size: BigInt(Math.round(f.file_size)),
|
||||
file_type: f.file_type ?? null,
|
||||
last_modified: f.last_modified != null ? BigInt(Math.round(f.last_modified)) : null,
|
||||
notes: f.notes ?? null,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
res.status(201).json(created.map(serializeFile));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/daily-statuses/:id/files
|
||||
router.get('/daily-statuses/:id/files', async (req, res, next) => {
|
||||
try {
|
||||
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
|
||||
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
||||
|
||||
const files = await prisma.sessionFile.findMany({
|
||||
where: { daily_status_id: req.params.id },
|
||||
orderBy: { created_at: 'asc' },
|
||||
});
|
||||
res.json(files.map(serializeFile));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/session-files/:id
|
||||
router.delete('/session-files/:id', async (req, res, next) => {
|
||||
try {
|
||||
const file = await prisma.sessionFile.findUnique({ where: { id: req.params.id } });
|
||||
if (!file) return res.status(404).json({ error: 'Session file not found' });
|
||||
await prisma.sessionFile.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// BigInt fields must be serialized to strings to survive JSON.stringify
|
||||
function serializeFile(f) {
|
||||
return {
|
||||
...f,
|
||||
file_size: f.file_size.toString(),
|
||||
last_modified: f.last_modified != null ? f.last_modified.toString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user