diff --git a/backend/prisma/migrations/20250423010000_session_files_matching_metadata/migration.sql b/backend/prisma/migrations/20250423010000_session_files_matching_metadata/migration.sql new file mode 100644 index 0000000..0a10a5e --- /dev/null +++ b/backend/prisma/migrations/20250423010000_session_files_matching_metadata/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "session_files" + ADD COLUMN "matched_identifier" TEXT, + ADD COLUMN "animal_id_string_snap" TEXT, + ADD COLUMN "animal_name_snap" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d6d7d59..6a5ebd1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -49,15 +49,18 @@ model DailyStatus { } model SessionFile { - id String @id @default(uuid()) - daily_status_id String - daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade) - filename String - file_size BigInt - file_type String? - last_modified BigInt? - notes String? - created_at DateTime @default(now()) + id String @id @default(uuid()) + daily_status_id String + daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade) + filename String + file_size BigInt + file_type String? + last_modified BigInt? + matched_identifier String? // the animal ID/name substring that matched in the filename + animal_id_string_snap String? // snapshot of animal.animal_id_string at registration time + animal_name_snap String? // snapshot of animal.animal_name at registration time + notes String? + created_at DateTime @default(now()) @@map("session_files") } diff --git a/backend/src/routes/sessionFiles.js b/backend/src/routes/sessionFiles.js index 6bdf849..5f2c4c3 100644 --- a/backend/src/routes/sessionFiles.js +++ b/backend/src/routes/sessionFiles.js @@ -2,7 +2,8 @@ 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 }, ...] +// Body: [{ filename, file_size, file_type, last_modified, +// matched_identifier, animal_id_string_snap, animal_name_snap, notes }, ...] router.post('/daily-statuses/:id/files', async (req, res, next) => { try { const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } }); @@ -26,12 +27,15 @@ router.post('/daily-statuses/:id/files', async (req, res, next) => { 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, + 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, + matched_identifier: f.matched_identifier ?? null, + animal_id_string_snap: f.animal_id_string_snap ?? null, + animal_name_snap: f.animal_name_snap ?? null, + notes: f.notes ?? null, }, }) ) diff --git a/frontend/src/components/FileDropZone.jsx b/frontend/src/components/FileDropZone.jsx index e7c9ab9..ec7566f 100644 --- a/frontend/src/components/FileDropZone.jsx +++ b/frontend/src/components/FileDropZone.jsx @@ -48,29 +48,58 @@ function fmtBytes(n) { return `${num} B`; } -/** Match an array of File objects against animals. Returns { matched: Map, unmatched: File[] } */ +/** + * Match an array of File objects against animals. + * Returns { + * matched: Map> + * unmatched: File[] + * } + * matchedIdentifier is the animal_id_string or animal_name value that triggered the match. + */ function matchFilesToAnimals(files, animals) { - const patterns = animals.map((a) => ({ animal: a, re: buildAnimalRegex(a) })).filter((x) => x.re); - const matched = new Map(animals.map((a) => [a.id, []])); + // Build patterns; each entry carries both alternatives so we know which one matched. + const entries = animals + .map((animal) => { + const alts = []; + if (animal.animal_id_string) alts.push({ value: animal.animal_id_string, re: new RegExp(`(? e.alts.length > 0); + + const matched = new Map(animals.map((a) => [a.id, []])); const unmatched = []; for (const file of files) { - const hit = patterns.find(({ re }) => re.test(file.name)); - if (hit) { - matched.get(hit.animal.id).push(file); - } else { - unmatched.push(file); + let found = false; + for (const { animal, alts } of entries) { + for (const { value, re } of alts) { + if (re.test(file.name)) { + matched.get(animal.id).push({ file, matchedIdentifier: value }); + found = true; + break; + } + } + if (found) break; } + if (!found) unmatched.push(file); } return { matched, unmatched }; } // ── Sub-components ──────────────────────────────────────────────────────────── -function FileRow({ file, onRemove }) { +function FileRow({ file, matchedIdentifier, onRemove }) { return (
{file.name} + {matchedIdentifier && ( + ↳ {matchedIdentifier} + )} {fmtBytes(file.size)} {classifyFile(file.name)} {onRemove && ( @@ -185,13 +214,16 @@ export default function FileDropZone({ rows, date }) { try { const results = await Promise.all( statusRows.map(async ({ animal, status }) => { - const files = dropped.matched.get(animal.id) ?? []; - if (files.length === 0) return { statusId: status.id, saved: [] }; - const payload = files.map((f) => ({ - filename: f.name, - file_size: f.size, - file_type: classifyFile(f.name), - last_modified: f.lastModified ?? null, + const entries = dropped.matched.get(animal.id) ?? []; + if (entries.length === 0) return { statusId: status.id, saved: [] }; + const payload = entries.map(({ file, matchedIdentifier }) => ({ + filename: file.name, + file_size: file.size, + file_type: classifyFile(file.name), + last_modified: file.lastModified ?? null, + matched_identifier: matchedIdentifier, + animal_id_string_snap: animal.animal_id_string ?? null, + animal_name_snap: animal.animal_name ?? null, })); const saved = await sessionFilesApi.create(status.id, payload); return { statusId: status.id, saved }; @@ -231,7 +263,7 @@ export default function FileDropZone({ rows, date }) { // ── Derived counts ───────────────────────────────────────────────────────── - const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0; + const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0; // entries are { file, matchedIdentifier } const totalUnmatched = dropped?.unmatched.length ?? 0; const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0); const hasSaved = totalSaved > 0; @@ -281,8 +313,8 @@ export default function FileDropZone({ rows, date }) { {/* Per-subject matched files */} {statusRows.map(({ animal, status }) => { - const files = dropped.matched.get(animal.id) ?? []; - if (files.length === 0) return null; + const entries = dropped.matched.get(animal.id) ?? []; + if (entries.length === 0) return null; return (
@@ -292,11 +324,11 @@ export default function FileDropZone({ rows, date }) { {animal.animal_id_string} )} - {files.length} file{files.length !== 1 ? 's' : ''} + {entries.length} file{entries.length !== 1 ? 's' : ''}
- {files.map((f, i) => ( - removeMatchedFile(animal.id, i)} /> + {entries.map(({ file, matchedIdentifier }, i) => ( + removeMatchedFile(animal.id, i)} /> ))}
diff --git a/frontend/tests/FileDropZone.test.jsx b/frontend/tests/FileDropZone.test.jsx index 1ad13fd..24d7432 100644 --- a/frontend/tests/FileDropZone.test.jsx +++ b/frontend/tests/FileDropZone.test.jsx @@ -249,8 +249,8 @@ describe('remove files before saving', () => { // ── Saving ───────────────────────────────────────────────────────────────────── describe('saving metadata', () => { - it('calls sessionFilesApi.create with correct payload for each subject', async () => { - const file = makeFile(`${DATE}_2852_trials.csv`); // size = 1 byte (content 'x') + it('calls sessionFilesApi.create with correct payload including matching metadata', async () => { + const file = makeFile(`${DATE}_2852_trials.csv`); const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size)); sessionFilesApi.create.mockResolvedValue([savedFile]); @@ -264,9 +264,34 @@ describe('saving metadata', () => { 's-a1', expect.arrayContaining([ expect.objectContaining({ - filename: `${DATE}_2852_trials.csv`, - file_size: file.size, - file_type: 'CSV', + filename: `${DATE}_2852_trials.csv`, + file_size: file.size, + file_type: 'CSV', + matched_identifier: '2852', + animal_id_string_snap: '2852', + animal_name_snap: 'Rat 2852', + }), + ]) + ) + ); + }); + + it('sets matched_identifier to animal_name when file matched by name', async () => { + const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null }; + const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } }; + sessionFilesApi.create.mockResolvedValue([]); + render(); + dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]); + await waitFor(() => screen.getByTestId('save-btn')); + fireEvent.click(screen.getByTestId('save-btn')); + await waitFor(() => + expect(sessionFilesApi.create).toHaveBeenCalledWith( + 's-b1', + expect.arrayContaining([ + expect.objectContaining({ + matched_identifier: 'Rat A', + animal_id_string_snap: null, + animal_name_snap: 'Rat A', }), ]) )