feat: store matching metadata on session file registration
Each saved file now records three additional fields:
matched_identifier — the literal animal_id_string or animal_name
substring that matched in the filename
animal_id_string_snap — snapshot of animal.animal_id_string at
registration time
animal_name_snap — snapshot of animal.animal_name at
registration time
This lets future consumers (scripts, exports, re-matching) identify
the subject from the file record alone, even if animal data changes.
The matched identifier is also shown in the drop preview UI (↳ 2852).
Tests: 25 total (+2 for new payload fields).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -49,15 +49,18 @@ model DailyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SessionFile {
|
model SessionFile {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
daily_status_id String
|
daily_status_id String
|
||||||
daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade)
|
daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade)
|
||||||
filename String
|
filename String
|
||||||
file_size BigInt
|
file_size BigInt
|
||||||
file_type String?
|
file_type String?
|
||||||
last_modified BigInt?
|
last_modified BigInt?
|
||||||
notes String?
|
matched_identifier String? // the animal ID/name substring that matched in the filename
|
||||||
created_at DateTime @default(now())
|
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")
|
@@map("session_files")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ const router = require('express').Router();
|
|||||||
const prisma = require('../lib/prisma');
|
const prisma = require('../lib/prisma');
|
||||||
|
|
||||||
// POST /api/daily-statuses/:id/files — register file metadata (batch)
|
// 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) => {
|
router.post('/daily-statuses/:id/files', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
|
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) =>
|
files.map((f) =>
|
||||||
prisma.sessionFile.create({
|
prisma.sessionFile.create({
|
||||||
data: {
|
data: {
|
||||||
daily_status_id: req.params.id,
|
daily_status_id: req.params.id,
|
||||||
filename: f.filename.trim(),
|
filename: f.filename.trim(),
|
||||||
file_size: BigInt(Math.round(f.file_size)),
|
file_size: BigInt(Math.round(f.file_size)),
|
||||||
file_type: f.file_type ?? null,
|
file_type: f.file_type ?? null,
|
||||||
last_modified: f.last_modified != null ? BigInt(Math.round(f.last_modified)) : null,
|
last_modified: f.last_modified != null ? BigInt(Math.round(f.last_modified)) : null,
|
||||||
notes: f.notes ?? 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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,29 +48,58 @@ function fmtBytes(n) {
|
|||||||
return `${num} B`;
|
return `${num} B`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Match an array of File objects against animals. Returns { matched: Map<animalId, File[]>, unmatched: File[] } */
|
/**
|
||||||
|
* Match an array of File objects against animals.
|
||||||
|
* Returns {
|
||||||
|
* matched: Map<animalId, Array<{ file, matchedIdentifier }>>
|
||||||
|
* unmatched: File[]
|
||||||
|
* }
|
||||||
|
* matchedIdentifier is the animal_id_string or animal_name value that triggered the match.
|
||||||
|
*/
|
||||||
function matchFilesToAnimals(files, animals) {
|
function matchFilesToAnimals(files, animals) {
|
||||||
const patterns = animals.map((a) => ({ animal: a, re: buildAnimalRegex(a) })).filter((x) => x.re);
|
// Build patterns; each entry carries both alternatives so we know which one matched.
|
||||||
const matched = new Map(animals.map((a) => [a.id, []]));
|
const entries = animals
|
||||||
|
.map((animal) => {
|
||||||
|
const alts = [];
|
||||||
|
if (animal.animal_id_string) alts.push({ value: animal.animal_id_string, re: new RegExp(`(?<![a-zA-Z0-9])${escapeRegex(animal.animal_id_string)}(?![a-zA-Z0-9])`, 'i') });
|
||||||
|
if (animal.animal_name) {
|
||||||
|
const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex);
|
||||||
|
const pat = words.length === 1 ? words[0] : words.join('[\\s_\\-]+');
|
||||||
|
alts.push({ value: animal.animal_name, re: new RegExp(`(?<![a-zA-Z0-9])${pat}(?![a-zA-Z0-9])`, 'i') });
|
||||||
|
}
|
||||||
|
return { animal, alts };
|
||||||
|
})
|
||||||
|
.filter((e) => e.alts.length > 0);
|
||||||
|
|
||||||
|
const matched = new Map(animals.map((a) => [a.id, []]));
|
||||||
const unmatched = [];
|
const unmatched = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const hit = patterns.find(({ re }) => re.test(file.name));
|
let found = false;
|
||||||
if (hit) {
|
for (const { animal, alts } of entries) {
|
||||||
matched.get(hit.animal.id).push(file);
|
for (const { value, re } of alts) {
|
||||||
} else {
|
if (re.test(file.name)) {
|
||||||
unmatched.push(file);
|
matched.get(animal.id).push({ file, matchedIdentifier: value });
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
}
|
}
|
||||||
|
if (!found) unmatched.push(file);
|
||||||
}
|
}
|
||||||
return { matched, unmatched };
|
return { matched, unmatched };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FileRow({ file, onRemove }) {
|
function FileRow({ file, matchedIdentifier, onRemove }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||||
<span className="font-mono text-gray-700 truncate min-w-0 flex-1">{file.name}</span>
|
<span className="font-mono text-gray-700 truncate min-w-0 flex-1">{file.name}</span>
|
||||||
|
{matchedIdentifier && (
|
||||||
|
<span className="text-blue-400 shrink-0 font-mono" title="matched by">↳ {matchedIdentifier}</span>
|
||||||
|
)}
|
||||||
<span className="text-gray-400 shrink-0">{fmtBytes(file.size)}</span>
|
<span className="text-gray-400 shrink-0">{fmtBytes(file.size)}</span>
|
||||||
<span className="text-gray-400 shrink-0 w-12 text-right">{classifyFile(file.name)}</span>
|
<span className="text-gray-400 shrink-0 w-12 text-right">{classifyFile(file.name)}</span>
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
@@ -185,13 +214,16 @@ export default function FileDropZone({ rows, date }) {
|
|||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
statusRows.map(async ({ animal, status }) => {
|
statusRows.map(async ({ animal, status }) => {
|
||||||
const files = dropped.matched.get(animal.id) ?? [];
|
const entries = dropped.matched.get(animal.id) ?? [];
|
||||||
if (files.length === 0) return { statusId: status.id, saved: [] };
|
if (entries.length === 0) return { statusId: status.id, saved: [] };
|
||||||
const payload = files.map((f) => ({
|
const payload = entries.map(({ file, matchedIdentifier }) => ({
|
||||||
filename: f.name,
|
filename: file.name,
|
||||||
file_size: f.size,
|
file_size: file.size,
|
||||||
file_type: classifyFile(f.name),
|
file_type: classifyFile(file.name),
|
||||||
last_modified: f.lastModified ?? null,
|
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);
|
const saved = await sessionFilesApi.create(status.id, payload);
|
||||||
return { statusId: status.id, saved };
|
return { statusId: status.id, saved };
|
||||||
@@ -231,7 +263,7 @@ export default function FileDropZone({ rows, date }) {
|
|||||||
|
|
||||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
// ── 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 totalUnmatched = dropped?.unmatched.length ?? 0;
|
||||||
const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0);
|
const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0);
|
||||||
const hasSaved = totalSaved > 0;
|
const hasSaved = totalSaved > 0;
|
||||||
@@ -281,8 +313,8 @@ export default function FileDropZone({ rows, date }) {
|
|||||||
|
|
||||||
{/* Per-subject matched files */}
|
{/* Per-subject matched files */}
|
||||||
{statusRows.map(({ animal, status }) => {
|
{statusRows.map(({ animal, status }) => {
|
||||||
const files = dropped.matched.get(animal.id) ?? [];
|
const entries = dropped.matched.get(animal.id) ?? [];
|
||||||
if (files.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -292,11 +324,11 @@ export default function FileDropZone({ rows, date }) {
|
|||||||
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
|
<span className="text-xs text-gray-400">{entries.length} file{entries.length !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
{files.map((f, i) => (
|
{entries.map(({ file, matchedIdentifier }, i) => (
|
||||||
<FileRow key={f.name + i} file={f} onRemove={() => removeMatchedFile(animal.id, i)} />
|
<FileRow key={file.name + i} file={file} matchedIdentifier={matchedIdentifier} onRemove={() => removeMatchedFile(animal.id, i)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -249,8 +249,8 @@ describe('remove files before saving', () => {
|
|||||||
// ── Saving ─────────────────────────────────────────────────────────────────────
|
// ── Saving ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('saving metadata', () => {
|
describe('saving metadata', () => {
|
||||||
it('calls sessionFilesApi.create with correct payload for each subject', async () => {
|
it('calls sessionFilesApi.create with correct payload including matching metadata', async () => {
|
||||||
const file = makeFile(`${DATE}_2852_trials.csv`); // size = 1 byte (content 'x')
|
const file = makeFile(`${DATE}_2852_trials.csv`);
|
||||||
const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size));
|
const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size));
|
||||||
sessionFilesApi.create.mockResolvedValue([savedFile]);
|
sessionFilesApi.create.mockResolvedValue([savedFile]);
|
||||||
|
|
||||||
@@ -264,9 +264,34 @@ describe('saving metadata', () => {
|
|||||||
's-a1',
|
's-a1',
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
filename: `${DATE}_2852_trials.csv`,
|
filename: `${DATE}_2852_trials.csv`,
|
||||||
file_size: file.size,
|
file_size: file.size,
|
||||||
file_type: 'CSV',
|
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(<FileDropZone rows={[row]} date={DATE} />);
|
||||||
|
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',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user