From 2e6f3e223b4ff5b942764c004fa60fc9178e1f31 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:47:38 -0700 Subject: [PATCH 01/85] refactor(activity): resolve sources with resolveSpecimenSources (ADR 0007). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/src/routes/__tests__/activity.test.ts | 8 +- packages/api/src/routes/activity.ts | 111 ++++++++---------- 2 files changed, 55 insertions(+), 64 deletions(-) diff --git a/packages/api/src/routes/__tests__/activity.test.ts b/packages/api/src/routes/__tests__/activity.test.ts index ccf8b4a..fb975d2 100644 --- a/packages/api/src/routes/__tests__/activity.test.ts +++ b/packages/api/src/routes/__tests__/activity.test.ts @@ -71,7 +71,9 @@ describe('Activity API', () => { const res = await ctx.request('/api/activity/recent', { method: 'GET' }) expect(res.status).toBe(200) - const data = (await res.json()) as { activity?: Array<{ id: number; type: string; label: string }> } + const data = (await res.json()) as { + activity?: Array<{ id: number; type: string; label: string; context?: string }> + } expect(Array.isArray(data.activity)).toBe(true) const specimenActivity = data.activity!.find( @@ -81,6 +83,10 @@ describe('Activity API', () => { expect(specimenActivity!.label).not.toMatch(/#\d+/) expect(specimenActivity!.label).toContain('HPV18 DBS Control') expect(specimenActivity!.label).toContain('HPV18-2024-001') + expect(specimenActivity).toMatchObject({ + label: 'DBS • HPV18 DBS Control (HPV18-2024-001)', + context: '2024-01-15', + }) }) it('labels subject specimens and their containers with subject and study', async () => { diff --git a/packages/api/src/routes/activity.ts b/packages/api/src/routes/activity.ts index c8d7bb0..c93a6cf 100644 --- a/packages/api/src/routes/activity.ts +++ b/packages/api/src/routes/activity.ts @@ -1,13 +1,10 @@ import { Hono } from 'hono' import type { Database } from '../db/client' -import { - specimen, - study, - storageContainer, - studySubject, +import { + specimen, + study, + storageContainer, specimenType, - controlBatch, - controlDefinition, micronixTube, cryovialTube, paper, @@ -16,14 +13,15 @@ import { import { sql, eq } from 'drizzle-orm' import { createAuthMiddleware } from '../middleware/auth' import { handleRouteError } from '../lib/error-handler' +import { resolveSpecimenSources } from '../lib/specimens/provenance' + +/** Upper bound for GET /activity/recent?limit=. */ +const MAX_RECENT_ACTIVITY = 50 /** * Create activity routes with database injection * @param database - Database instance (required) */ -/** Upper bound for GET /activity/recent?limit=. */ -const MAX_RECENT_ACTIVITY = 50 - export function createActivityRoutes(database: Database): Hono { const activity = new Hono() const authMiddleware = createAuthMiddleware(database) @@ -35,8 +33,8 @@ export function createActivityRoutes(database: Database): Hono { const requested = parseInt(c.req.query('limit') || '10') const limit = Number.isFinite(requested) ? Math.min(Math.max(requested, 1), MAX_RECENT_ACTIVITY) : 10 - // Recent specimens, joined to their subject/study or batch/definition in one query - // (never expose DB IDs in labels) + // Recent specimens with their type; the Source comes from the shared resolver (ADR 0007). + // Labels never expose DB IDs. const recentSpecimens = await database .select({ id: specimen.id, @@ -45,53 +43,12 @@ export function createActivityRoutes(database: Database): Hono { specimenTypeName: specimenType.name, collectionDate: specimen.collectionDate, controlBatchId: specimen.controlBatchId, - studySubjectId: specimen.studySubjectId, - batchName: controlBatch.name, - productionDate: controlBatch.productionDate, - definitionName: controlDefinition.name, - subjectName: studySubject.name, - studyTitle: study.title, - studyShortCode: study.shortCode, }) .from(specimen) .leftJoin(specimenType, eq(specimen.specimenTypeId, specimenType.id)) - .leftJoin(controlBatch, eq(specimen.controlBatchId, controlBatch.id)) - .leftJoin(controlDefinition, eq(controlBatch.controlDefinitionId, controlDefinition.id)) - .leftJoin(studySubject, eq(specimen.studySubjectId, studySubject.id)) - .leftJoin(study, eq(studySubject.studyId, study.id)) .orderBy(sql`COALESCE(${specimen.lastUpdated}, ${specimen.created}) DESC`) .limit(limit) - const enrichedSpecimens = recentSpecimens.map((spec) => { - const specimenTypeName = spec.specimenTypeName || 'Specimen' - let label: string = specimenTypeName - let context: string | undefined = undefined - - if (spec.controlBatchId) { - if (spec.batchName && spec.definitionName) { - label = `${specimenTypeName} • ${spec.definitionName} (${spec.batchName})` - context = spec.collectionDate || spec.productionDate || undefined - } else { - label = `${specimenTypeName} • Control batch` - } - } else if (spec.studySubjectId && spec.subjectName) { - if (spec.studyShortCode) { - label = `${specimenTypeName} • ${spec.subjectName} (${spec.studyShortCode})` - context = spec.studyTitle ?? undefined - } else { - label = `${specimenTypeName} • ${spec.subjectName}` - } - } - - return { - id: spec.id, - type: 'specimen' as const, - timestamp: spec.lastUpdated || spec.created || '', - label, - context, - } - }) - // Get recent studies with enriched data const recentStudies = await database .select({ @@ -115,7 +72,7 @@ export function createActivityRoutes(database: Database): Hono { })) ) - // Recent containers, joined to their subtype row and specimen/subject/study in one query + // Recent containers, joined to their subtype row and specimen type in one query const recentContainers = await database .select({ id: storageContainer.id, @@ -131,9 +88,8 @@ export function createActivityRoutes(database: Database): Hono { paperSublabel: paper.sublabel, wellId: staticWell.id, wellPosition: staticWell.position, + specimenId: storageContainer.specimenId, specimenTypeName: specimenType.name, - subjectName: studySubject.name, - studyShortCode: study.shortCode, }) .from(storageContainer) .leftJoin(micronixTube, eq(micronixTube.id, storageContainer.id)) @@ -142,11 +98,42 @@ export function createActivityRoutes(database: Database): Hono { .leftJoin(staticWell, eq(staticWell.id, storageContainer.id)) .leftJoin(specimen, eq(storageContainer.specimenId, specimen.id)) .leftJoin(specimenType, eq(specimen.specimenTypeId, specimenType.id)) - .leftJoin(studySubject, eq(specimen.studySubjectId, studySubject.id)) - .leftJoin(study, eq(studySubject.studyId, study.id)) .orderBy(sql`COALESCE(${storageContainer.lastUpdated}, ${storageContainer.created}) DESC`) .limit(limit) + // One batched Source lookup for every specimen in the feed: a fixed number of queries. + const sources = await resolveSpecimenSources(database, [ + ...recentSpecimens.map((s) => s.id), + ...recentContainers.flatMap((c) => (c.specimenId != null ? [c.specimenId] : [])), + ]) + + const enrichedSpecimens = recentSpecimens.map((spec) => { + const specimenTypeName = spec.specimenTypeName || 'Specimen' + const source = sources.get(spec.id) ?? null + let label: string = specimenTypeName + let context: string | undefined = undefined + + if (spec.controlBatchId) { + if (source?.type === 'control' && source.definitionName) { + label = `${specimenTypeName} • ${source.definitionName} (${source.name})` + context = spec.collectionDate || source.productionDate || undefined + } else { + label = `${specimenTypeName} • Control batch` + } + } else if (source?.type === 'subject') { + label = `${specimenTypeName} • ${source.name} (${source.study.code})` + context = source.study.title + } + + return { + id: spec.id, + type: 'specimen' as const, + timestamp: spec.lastUpdated || spec.created || '', + label, + context, + } + }) + const enrichedContainers = recentContainers.map((container) => { let containerType = 'container' let barcode: string | null = null @@ -184,11 +171,9 @@ export function createActivityRoutes(database: Database): Hono { } let context: string | undefined = container.specimenTypeName ?? undefined - if (container.subjectName) { - const subject = container.studyShortCode - ? `${container.subjectName} (${container.studyShortCode})` - : container.subjectName - context = `${container.specimenTypeName || ''} • ${subject}`.trim() + const source = container.specimenId != null ? sources.get(container.specimenId) : null + if (source?.type === 'subject') { + context = `${container.specimenTypeName || ''} • ${source.name} (${source.study.code})`.trim() } return { From 34ba32f7a838fa28ee8d5a2dacd67c3814f6caeb Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:48:40 -0700 Subject: [PATCH 02/85] test(db): close the snapshot test's database and note why it skips the entry point. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/db/README.md | 2 +- packages/api/src/db/__tests__/initial-schema-snapshot.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/src/db/README.md b/packages/api/src/db/README.md index bbefac7..f7771a0 100644 --- a/packages/api/src/db/README.md +++ b/packages/api/src/db/README.md @@ -10,7 +10,7 @@ Use `openOperationalDatabase(optionalPath?)` from the client module. It resolves Multi-statement writes that `await` must use `withWriteTransaction` from `write-transaction.ts`. Drizzle's bun-sqlite `db.transaction(async () => …)` commits when the callback first yields; it does not await the returned Promise. ESLint rejects an `async` callback passed to `.transaction(...)`. -Tests should use the same entry point (e.g. `openOperationalDatabase(':memory:')` via `setupTestDatabase()`). +Tests should use the same entry point (e.g. `openOperationalDatabase(':memory:')` via `setupTestDatabase()`), except a test that checks the snapshot itself: `__tests__/initial-schema-snapshot.test.ts` applies `initial_schema.sql` alone, because the entry point would run pending migrations on top and hide a stale snapshot. ## Schema version diff --git a/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts b/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts index 019c50c..b5a972c 100644 --- a/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts +++ b/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterAll } from 'vitest' import { Database } from 'bun:sqlite' import { is } from 'drizzle-orm' import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core' @@ -13,6 +13,7 @@ import { CURRENT_SCHEMA_VERSION, getRecordedSchemaVersion } from '../schema-evol describe('initial_schema.sql snapshot', () => { const sqlite = new Database(':memory:') applyInitialSchema(sqlite) + afterAll(() => sqlite.close()) const drizzleTables = Object.values(schema) .filter((value): value is SQLiteTable => is(value, SQLiteTable)) From dd642a4a6ada6185c4a4b7f44d1f415e0a55275f Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:48:44 -0700 Subject: [PATCH 03/85] docs: cite ADRs as "ADR 0006" in the files this PR touches. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 2 +- packages/api/src/db/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 973bd16..9a57709 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -4,7 +4,7 @@ - Name a domain concept with the term `CONTEXT.md` defines for it, in code, test names, issue titles, and proposals. A term the glossary lists under _Avoid_ is a synonym to replace with the defined term. - When a concept has no glossary entry, say so in the issue, PR, or proposal before introducing a term for it. -- A change that contradicts an ADR in `docs/adr/` names the ADR and gives the reason to reopen it, in the PR or proposal. For example: _Contradicts ADR-0006 (unified container inbound write), but worth reopening because…_ +- A change that contradicts an ADR in `docs/adr/` names the ADR and gives the reason to reopen it, in the PR or proposal. For example: _Contradicts ADR 0006 (unified container inbound write), but worth reopening because…_ ## Tests diff --git a/packages/api/src/db/README.md b/packages/api/src/db/README.md index eca6ea5..b33f559 100644 --- a/packages/api/src/db/README.md +++ b/packages/api/src/db/README.md @@ -2,7 +2,7 @@ SampleDB stores **operational data** (studies, subjects, specimens, containers, controls, and related records) in SQLite. This package owns how that file is opened, versioned, and evolved. -See [ADR-0003: SQLite schema evolution](../../../docs/adr/0003-sqlite-schema-evolution.md) for rationale (snapshot + deltas, no drizzle-kit migrate, fail-hard on error). +See [ADR 0003: SQLite schema evolution](../../../docs/adr/0003-sqlite-schema-evolution.md) for rationale (snapshot + deltas, no drizzle-kit migrate, fail-hard on error). ## Changing the schema From bfa8d4016a9fd4d27474423a3aedd294e30dd10e Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:48:57 -0700 Subject: [PATCH 04/85] docs: write the coding standards as second-person imperatives. The repo's docs spell it "behaviour" (7 uses in 4 files against 1 "behavior"), which CODING_STANDARDS.md already follows. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 9a57709..c55d9f1 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -2,13 +2,13 @@ ## Domain language -- Name a domain concept with the term `CONTEXT.md` defines for it, in code, test names, issue titles, and proposals. A term the glossary lists under _Avoid_ is a synonym to replace with the defined term. -- When a concept has no glossary entry, say so in the issue, PR, or proposal before introducing a term for it. -- A change that contradicts an ADR in `docs/adr/` names the ADR and gives the reason to reopen it, in the PR or proposal. For example: _Contradicts ADR 0006 (unified container inbound write), but worth reopening because…_ +- Name a domain concept with the term `CONTEXT.md` defines for it, in code, test names, issue titles, and proposals. Treat a term the glossary lists under _Avoid_ as a synonym, and replace it with the defined term. +- When a concept has no glossary entry, say so in the issue, PR, or proposal before you introduce a term for it. +- When a change contradicts an ADR in `docs/adr/`, name the ADR and give the reason to reopen it, in the PR or proposal. For example: _Contradicts ADR 0006 (unified container inbound write), but worth reopening because…_ ## Tests -- A test asserts the expected behaviour. When it fails, fix the code under test and keep the assertion as strict as before. A new test may land failing if it states the expected behaviour. +- Assert the expected behaviour. When a test fails, fix the code under test and keep the assertion as strict as before. You may land a new test that fails, if it states the expected behaviour. ## API @@ -16,16 +16,16 @@ Paths are relative to `packages/api/`. ### Database -- A write of several statements that `await`s runs inside `withWriteTransaction` from `src/db/write-transaction.ts`. -- Change the schema with a numbered migration, following the recipe in `src/db/README.md`. DDL lives only in the migrations and `initial_schema.sql`. -- Code opens a database through `openOperationalDatabase`. +- Run a write of several statements that `await`s inside `withWriteTransaction` from `src/db/write-transaction.ts`. +- Change the schema with a numbered migration, following the recipe in `src/db/README.md`. Keep DDL in the migrations and `initial_schema.sql` only. +- Open a database through `openOperationalDatabase`. ### API tests -- Each test gets its own database from `setupTestDatabase()` and closes it with `cleanupTestDatabase()` in `afterEach`. +- Give each test its own database from `setupTestDatabase()`, and close it with `cleanupTestDatabase()` in `afterEach`. - Seed rows with the factories in `src/__tests__/helpers/factories.ts`. -- A route behind session auth is tested through `setupAuthenticatedRouteTest`. -- A CRUD route with `checkInUse` has a delete test for both the in-use and the free case. +- Test a route behind session auth through `setupAuthenticatedRouteTest`. +- For a CRUD route with `checkInUse`, write a delete test for both the in-use and the free case. ## Web @@ -35,7 +35,7 @@ Paths are relative to `packages/web/`. ### Web tests -- Components render through `await render(...)` from `src/__tests__/helpers/render`. -- A test mocks the domain module the code imports with `createMockedDomainModule`. An override replaces a whole `*Api` object and resolves to the same body the real module returns. +- Render components with `await render(...)` from `src/__tests__/helpers/render`. +- Mock the domain module the code imports with `createMockedDomainModule`. Make each override replace a whole `*Api` object and resolve to the same body the real module returns. - Query elements by role or label (`getByRole`, `getByLabelText`), and drive interactions with `@testing-library/user-event`. -- A mutation hook's test asserts which queries it invalidates. +- In a mutation hook's test, assert which queries it invalidates. From bf9746b38005f17d967f6bbb44dc60ba4c08b644 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:02 -0700 Subject: [PATCH 05/85] docs: allow read-only tools that must not migrate to skip openOperationalDatabase. check-integrity.ts opens a backup with new Database(..., { readonly: true }) on purpose, so the rule now names that exception and leaves room for others. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index c55d9f1..042d2c9 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -18,7 +18,7 @@ Paths are relative to `packages/api/`. - Run a write of several statements that `await`s inside `withWriteTransaction` from `src/db/write-transaction.ts`. - Change the schema with a numbered migration, following the recipe in `src/db/README.md`. Keep DDL in the migrations and `initial_schema.sql` only. -- Open a database through `openOperationalDatabase`. +- Open a database through `openOperationalDatabase`, which runs schema evolution. Open the file directly only when the code must not migrate it, and say why in a comment. For example, `src/scripts/check-integrity.ts` opens a backup with `new Database(path, { readonly: true })` so that checking it leaves it unchanged. ### API tests From 21cdf4088ce00966ad25a36684508ce83d3d676d Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:02 -0700 Subject: [PATCH 06/85] refactor(api): find derivation paper parents through the placement module (ADR 0006). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/container-write-placement.ts | 6 +++++- packages/api/src/lib/derivations-csv.ts | 15 ++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/api/src/lib/container-write-placement.ts b/packages/api/src/lib/container-write-placement.ts index 267f9c6..d3e6ebf 100644 --- a/packages/api/src/lib/container-write-placement.ts +++ b/packages/api/src/lib/container-write-placement.ts @@ -194,7 +194,11 @@ async function ensureGridCollectionId( return id } -async function lookupParentCollectionId( +/** + * Find an existing box or bag (a paper Sheet's parent) by id or name, or `null` when a + * named one does not exist. Throws when an id does not exist or neither id nor name is given. + */ +export async function lookupParentCollectionId( database: DatabaseOrTransaction, parent: { type: 'box' | 'bag'; id?: number; name?: string; locationId?: number }, collectionMap?: Map diff --git a/packages/api/src/lib/derivations-csv.ts b/packages/api/src/lib/derivations-csv.ts index 78ad65b..dd73588 100644 --- a/packages/api/src/lib/derivations-csv.ts +++ b/packages/api/src/lib/derivations-csv.ts @@ -1,8 +1,6 @@ import type { Database } from '../db/client' import type { DatabaseOrTransaction } from './db-types' import { - bag, - box, containerDerivation, cryovialBox, cryovialTube, @@ -28,6 +26,7 @@ import { withWriteTransaction } from '../db/write-transaction' import { resolveParentContainerId } from './derivations/parent-container-resolver' import { normalizePosition } from './normalize-position' import { checkGridPositionOccupancy } from './container-occupancy' +import { lookupParentCollectionId } from './container-write-placement' /** * Map technical DB/constraint errors to messages that are clear for non-technical users. @@ -350,13 +349,11 @@ async function resolveCollectionId( // Paper goes on a sheet in the named box or bag; the sheet is created if missing, // but the box or bag itself must already exist. if (!collectionName) return { status: 'will_be_created' } - const parentTable = paperParentType === 'bag' ? bag : box - const parent = await database - .select({ id: parentTable.id }) - .from(parentTable) - .where(eq(parentTable.name, collectionName.trim())) - .get() - return { id: parent?.id, status: parent ? 'existing' : 'will_be_created' } + const parentId = await lookupParentCollectionId(database, { + type: paperParentType, + name: collectionName.trim(), + }) + return parentId != null ? { id: parentId, status: 'existing' } : { status: 'will_be_created' } } return { status: 'will_be_created' } From d2797d82336343b020e2cbdceadc2e8dddc95153 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:09 -0700 Subject: [PATCH 07/85] docs: say when the hand-rolled session helpers are acceptable. The API test README offered loginAndGetCookie and friends with no caveat, against the rule to use setupAuthenticatedRouteTest. Logging in by hand is now limited to tests of the login flow, and the client wrapper is shown on top of the adapter's cookie, matching how the tests use them. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 2 +- packages/api/src/__tests__/README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 042d2c9..b5d718a 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -24,7 +24,7 @@ Paths are relative to `packages/api/`. - Give each test its own database from `setupTestDatabase()`, and close it with `cleanupTestDatabase()` in `afterEach`. - Seed rows with the factories in `src/__tests__/helpers/factories.ts`. -- Test a route behind session auth through `setupAuthenticatedRouteTest`. +- Test a route behind session auth through `setupAuthenticatedRouteTest`. Log in by hand with `loginAndGetCookie` only when the login flow is under test, as in the auth route and middleware tests. - For a CRUD route with `checkInUse`, write a delete test for both the in-use and the free case. ## Web diff --git a/packages/api/src/__tests__/README.md b/packages/api/src/__tests__/README.md index bc11df6..2f63d03 100644 --- a/packages/api/src/__tests__/README.md +++ b/packages/api/src/__tests__/README.md @@ -83,7 +83,8 @@ describe('Search API', () => { `helpers/test-client.ts`: - `getResponseData(response)` unpacks `{ data }` from a standard API response. -- `authenticatedRequest(app, path, options)` sends a request with a session cookie. -- `loginAndGetCookie` and `createAuthenticatedClientWrapper` manage sessions by hand. +- `authenticatedRequest(app, path, options)` sends a request with a session cookie. `ctx.request` calls it for you. +- `createAuthenticatedClientWrapper(client, ctx.cookie)` adds the session cookie to a typed `hono/testing` client, for tests that build one on top of `setupAuthenticatedRouteTest`. +- `loginAndGetCookie` logs in and returns the session cookie. Call it directly only in tests of the login flow itself, such as `routes/__tests__/auth.test.ts` and `middleware/__tests__/auth.test.ts`. `helpers/auth-helpers.ts` creates users and sessions and seeds the password, session, and pagination settings. From 08e081cdb1ae034dd2008928822fe837c5fc0f4b Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:17 -0700 Subject: [PATCH 08/85] docs: put the reason for withWriteTransaction back next to the rule. The db README defers to CODING_STANDARDS.md for the transaction rule, but the move dropped why: Drizzle's bun-sqlite transaction commits at the first await. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index b5d718a..ada7a33 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -16,7 +16,7 @@ Paths are relative to `packages/api/`. ### Database -- Run a write of several statements that `await`s inside `withWriteTransaction` from `src/db/write-transaction.ts`. +- Run a write of several statements that `await`s inside `withWriteTransaction` from `src/db/write-transaction.ts`. Drizzle's bun-sqlite `db.transaction(async () => …)` commits at the callback's first `await` instead of awaiting the returned Promise, so the rest of the write runs outside the transaction. ESLint rejects an `async` callback passed to `.transaction(...)`. - Change the schema with a numbered migration, following the recipe in `src/db/README.md`. Keep DDL in the migrations and `initial_schema.sql` only. - Open a database through `openOperationalDatabase`, which runs schema evolution. Open the file directly only when the code must not migrate it, and say why in a comment. For example, `src/scripts/check-integrity.ts` opens a backup with `new Database(path, { readonly: true })` so that checking it leaves it unchanged. From 85cb068eed7f2c8efdacdf77dd8dc93d70e84518 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:24 -0700 Subject: [PATCH 09/85] docs: restore the test rules the README rewrite dropped. The old API and web test READMEs asked for edge-case tests, loading and error state tests, and waitFor or findBy for async work. The rewrite cut them instead of moving them. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index ada7a33..607b49d 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -26,6 +26,7 @@ Paths are relative to `packages/api/`. - Seed rows with the factories in `src/__tests__/helpers/factories.ts`. - Test a route behind session auth through `setupAuthenticatedRouteTest`. Log in by hand with `loginAndGetCookie` only when the login flow is under test, as in the auth route and middleware tests. - For a CRUD route with `checkInUse`, write a delete test for both the in-use and the free case. +- Test a route's error cases as well as its success: validation errors, 404s, and duplicates. ## Web @@ -38,4 +39,6 @@ Paths are relative to `packages/web/`. - Render components with `await render(...)` from `src/__tests__/helpers/render`. - Mock the domain module the code imports with `createMockedDomainModule`. Make each override replace a whole `*Api` object and resolve to the same body the real module returns. - Query elements by role or label (`getByRole`, `getByLabelText`), and drive interactions with `@testing-library/user-event`. +- Wait for async results with `findBy*` queries or `waitFor`. +- Test a component's loading and error states, not only its loaded state. - In a mutation hook's test, assert which queries it invalidates. From acb19ecbe6560f79db33cea31016e79731ccb615 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:28 -0700 Subject: [PATCH 10/85] docs: keep the whole-*Api override rule in CODING_STANDARDS.md only. The web test README restated it; it now shows how createMockedDomainModule works and points to the rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/__tests__/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/__tests__/README.md b/packages/web/src/__tests__/README.md index 012bbc7..448cb19 100644 --- a/packages/web/src/__tests__/README.md +++ b/packages/web/src/__tests__/README.md @@ -23,7 +23,7 @@ it('handles a click', async () => { ## Mocking the API -Code calls the API through domain modules under `src/lib/api/`, so a test mocks the module the code imports. `createMockedDomainModule(domain, overrides)` in `helpers/mock-api.ts` loads the real module and swaps in the `*Api` objects you pass: +`createMockedDomainModule(domain, overrides)` in `helpers/mock-api.ts` loads the real module with `vi.importActual` and replaces each export named in `overrides`. What to pass is under Web tests in `CODING_STANDARDS.md`: ```typescript import { studiesApi } from '../../lib/api/studies' From 8e96e89a775b03cb2e56972a7afd98dca707c2ed Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:40 -0700 Subject: [PATCH 11/85] docs: describe the pre-commit hook and ci:verify in README.md only. AGENTS.md now points to the paragraph instead of restating it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2af56e1..a7ac284 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - Web tests: `cd packages/web && bun run test -- `. Bun's own runner (`bun test`) hangs on React component tests; Vitest exits on its own, so piping it to `tail` or `grep` is safe. - API tests: `cd packages/api && bun test src/...`. - Lint is clean, so any lint error is yours. -- The pre-commit hook only typechecks and lints. Run `bun run ci:verify`, which CI runs on every PR, before pushing. +- Run `bun run ci:verify` before pushing. The end of Build in `README.md` says what it and the pre-commit hook check. ## Where to look From be5a54714fc8085cab527f8383788ad6b788a2b5 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:45 -0700 Subject: [PATCH 12/85] docs: name the five triage labels in AGENTS.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a7ac284..e7ca05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ ## Agent skills - **Issue tracker**: GitHub Issues on `eppicenter/sampledb3`. "Publish to the issue tracker" is `gh issue create`; "fetch the relevant ticket" is `gh issue view --comments`. -- **Triage labels**: each triage role is a GitHub label with the role's name. +- **Triage labels**: each of the five triage roles is a GitHub label with the role's name: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, and `wontfix`. - **Domain docs**: `CONTEXT.md` is the glossary, `docs/adr/` holds the decisions, and `packages/docs/` is the user guide. ## Checks From 35f4f1d83ff71c81b9b3d2dfe6c44b42f19fd17f Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:45 -0700 Subject: [PATCH 13/85] docs: rename the AGENTS.md "Checks" heading so it isn't confused with the Checks workflow. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e7ca05f..84d0d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - **Triage labels**: each of the five triage roles is a GitHub label with the role's name: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, and `wontfix`. - **Domain docs**: `CONTEXT.md` is the glossary, `docs/adr/` holds the decisions, and `packages/docs/` is the user guide. -## Checks +## Running tests and checks - Web tests: `cd packages/web && bun run test -- `. Bun's own runner (`bun test`) hangs on React component tests; Vitest exits on its own, so piping it to `tail` or `grep` is safe. - API tests: `cd packages/api && bun test src/...`. From c0f3e5532f2c8fb4c65fb4fae5c2477643423a0c Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:52 -0700 Subject: [PATCH 14/85] docs: say what CODING_STANDARDS.md holds in the AGENTS.md pointer. The old pointer covered nearly every task, so it routed nowhere. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 84d0d2a..3e4c624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ ## Where to look -- Writing code, tests, issues, or proposals, or reviewing a change: `CODING_STANDARDS.md`. +- Rules for naming domain concepts, contradicting an ADR, failing tests, database access, calling the API from the web, and writing API and web tests: `CODING_STANDARDS.md`. The `code-review` skill checks changes against it. - Writing an API test: `packages/api/src/__tests__/README.md`. - Writing a web test: `packages/web/src/__tests__/README.md`. - Changing the schema or adding a migration: `packages/api/src/db/README.md`. From 063c188c7ee1998fa6a417a779d693fabf9f7ed7 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:52 -0700 Subject: [PATCH 15/85] docs: give AGENTS.md an H1 heading, as CODING_STANDARDS.md has. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3e4c624..d92a443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,5 @@ +# Agent guide + ## Agent skills - **Issue tracker**: GitHub Issues on `eppicenter/sampledb3`. "Publish to the issue tracker" is `gh issue create`; "fetch the relevant ticket" is `gh issue view --comments`. From 1e7285b0d638646daa4c6a4724e3ce3798a8e6e3 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:49:25 -0700 Subject: [PATCH 16/85] test(db): compare the snapshot's indexes, foreign keys, NOT NULL and types with the migrated schema. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/db/README.md | 2 +- .../__tests__/fixtures/initial_schema_v1.sql | 384 ++++++++++++++++++ .../__tests__/initial-schema-snapshot.test.ts | 117 +++++- 3 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/db/__tests__/fixtures/initial_schema_v1.sql diff --git a/packages/api/src/db/README.md b/packages/api/src/db/README.md index f7771a0..35c19d2 100644 --- a/packages/api/src/db/README.md +++ b/packages/api/src/db/README.md @@ -35,7 +35,7 @@ Migration `001` is **only** for legacy files; fresh installs from the snapshot s 3. Add `packages/api/src/db/migrations/00N_short_name.sql` (statements separated with `--> statement-breakpoint`). 4. Bump `CURRENT_SCHEMA_VERSION` in `schema-evolution.ts`. 5. Run `bun run build` in `packages/api` — `scripts/runtime-assets.ts` copies migrations into `dist/db/migrations` and verifies the dist layout. If you add other on-disk runtime assets, register them in `RUNTIME_ASSETS` in that script. -6. Refresh `initial_schema.sql` so **new** empty databases match the latest shape (merge generated SQL; keep `INSERT INTO schema_version` at `CURRENT_SCHEMA_VERSION`). `__tests__/initial-schema-snapshot.test.ts` fails until the snapshot holds every table and column in `schema.ts`. +6. Refresh `initial_schema.sql` so **new** empty databases match the latest shape (merge generated SQL; keep `INSERT INTO schema_version` at `CURRENT_SCHEMA_VERSION`). `__tests__/initial-schema-snapshot.test.ts` fails until the snapshot holds every table and column in `schema.ts`, and until its columns, declared types, NOT NULL, indexes and foreign keys match a version 1 install (`__tests__/fixtures/initial_schema_v1.sql`, never edited) brought forward by the migrations. 7. Extend the legacy fixtures if the migration touches a table they don't create. Older-version tests in `__tests__/schema-evolution.test.ts` build minimal databases through `createTablesForLaterMigrations`, and `__tests__/client.test.ts` creates its own tables inline; both must contain every table and column a later migration alters. Do **not** add inline DDL to `open.ts` or connection code. diff --git a/packages/api/src/db/__tests__/fixtures/initial_schema_v1.sql b/packages/api/src/db/__tests__/fixtures/initial_schema_v1.sql new file mode 100644 index 0000000..74a73aa --- /dev/null +++ b/packages/api/src/db/__tests__/fixtures/initial_schema_v1.sql @@ -0,0 +1,384 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + username TEXT UNIQUE, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_login TEXT, + deleted_at TEXT, + approved_at TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + expires_at INTEGER NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS tag ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS unit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + category TEXT NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS study ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL UNIQUE, + description TEXT, + short_code TEXT NOT NULL UNIQUE, + is_longitudinal INTEGER NOT NULL, + lead_person TEXT NOT NULL, + created TEXT NOT NULL, + last_updated TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS study_title_idx ON study(title);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS study_short_code_idx ON study(short_code);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS study_subject ( + id INTEGER PRIMARY KEY, + study_id INTEGER NOT NULL REFERENCES study(id), + name TEXT NOT NULL, + created TEXT NOT NULL, + last_updated TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS study_subject_study_id_idx ON study_subject(study_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS study_subject_name_idx ON study_subject(name);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS control_definition ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + control_type TEXT NOT NULL CHECK (control_type IN ('blood', 'plasma_positive', 'plasma_negative', 'antibody', 'extraction', 'negative')), + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS control_batch ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + control_definition_id INTEGER NOT NULL REFERENCES control_definition(id), + name TEXT NOT NULL UNIQUE, + production_date TEXT, + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS control_batch_control_definition_id_idx ON control_batch(control_definition_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS reagent ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + reagent_type TEXT NOT NULL, + vendor TEXT, + catalog_number TEXT, + lot_number TEXT, + received_date TEXT, + expiration_date TEXT, + storage_temp TEXT, + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS cell_line ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + species TEXT NOT NULL, + strain TEXT, + source TEXT, + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS plasmid ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + backbone TEXT, + insert_name TEXT, + insert_size_bp INTEGER, + resistance TEXT, + source TEXT, + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS standard ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + standard_type TEXT NOT NULL, + manufacturer TEXT, + catalog_number TEXT, + lot_number TEXT, + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS specimen_type ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created TEXT NOT NULL, + last_updated TEXT NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS specimen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + study_subject_id INTEGER REFERENCES study_subject(id), + control_batch_id INTEGER REFERENCES control_batch(id), + specimen_type_id INTEGER NOT NULL REFERENCES specimen_type(id), + collection_date TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + CHECK ((study_subject_id IS NOT NULL AND control_batch_id IS NULL) OR + (study_subject_id IS NULL AND control_batch_id IS NOT NULL)) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS specimen_study_subject_id_idx ON specimen(study_subject_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS specimen_control_batch_id_idx ON specimen(control_batch_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS specimen_collection_date_idx ON specimen(collection_date);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS specimen_specimen_type_id_idx ON specimen(specimen_type_id);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS idx_specimen_study_subject_type_date ON specimen(study_subject_id, specimen_type_id, collection_date) WHERE study_subject_id IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS idx_specimen_control_batch_type_date ON specimen(control_batch_id, specimen_type_id, collection_date) WHERE control_batch_id IS NOT NULL;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS storage_container ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + specimen_id INTEGER NOT NULL REFERENCES specimen(id), + comment TEXT, + total_quantity REAL DEFAULT 1.0, + remaining_quantity REAL DEFAULT 1.0, + unit_id INTEGER NOT NULL REFERENCES unit(id), + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS storage_container_specimen_id_idx ON storage_container(specimen_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS storage_container_tag ( + storage_container_id INTEGER NOT NULL REFERENCES storage_container(id), + tag_id INTEGER NOT NULL REFERENCES tag(id), + PRIMARY KEY (storage_container_id, tag_id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS location ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES location(id), + name TEXT NOT NULL, + storage_type_id TEXT, + description TEXT, + can_contain_collections INTEGER NOT NULL DEFAULT 0, + path TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + UNIQUE (parent_id, name), + CHECK ((parent_id IS NULL AND storage_type_id IS NOT NULL) OR + (parent_id IS NOT NULL AND storage_type_id IS NULL)) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_location_parent_id ON location(parent_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_location_path ON location(path);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS micronix_plate ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + name TEXT NOT NULL UNIQUE, + barcode TEXT UNIQUE, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS micronix_plate_location_id_idx ON micronix_plate(location_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS micronix_tube ( + id INTEGER PRIMARY KEY REFERENCES storage_container(id), + collection_id INTEGER NOT NULL REFERENCES micronix_plate(id), + barcode TEXT NOT NULL UNIQUE, + position TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS cryovial_box ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + name TEXT NOT NULL UNIQUE, + barcode TEXT UNIQUE, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS cryovial_box_location_id_idx ON cryovial_box(location_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS cryovial_tube ( + id INTEGER PRIMARY KEY REFERENCES storage_container(id), + collection_id INTEGER NOT NULL REFERENCES cryovial_box(id), + barcode TEXT, + position TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS box ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + name TEXT NOT NULL UNIQUE, + created TEXT NOT NULL, + last_updated TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS bag ( + id INTEGER PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES location(id), + name TEXT NOT NULL UNIQUE, + created TEXT NOT NULL, + last_updated TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS bag_location_id_idx ON bag(location_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS sheet ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + box_id INTEGER REFERENCES box(id), + bag_id INTEGER REFERENCES bag(id), + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + UNIQUE (name, box_id, bag_id), + CHECK ((box_id IS NOT NULL AND bag_id IS NULL) OR + (box_id IS NULL AND bag_id IS NOT NULL) OR + (box_id IS NULL AND bag_id IS NULL)) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS paper ( + id INTEGER PRIMARY KEY REFERENCES storage_container(id), + sheet_id INTEGER NOT NULL REFERENCES sheet(id), + barcode TEXT, + position TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS static_well ( + id INTEGER PRIMARY KEY REFERENCES storage_container(id), + collection_id INTEGER NOT NULL REFERENCES micronix_plate(id), + position TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS strain ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS storage_type ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL +);--> statement-breakpoint +INSERT INTO schema_version (version) VALUES (1);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS settings ( + key TEXT NOT NULL, + user_id INTEGER REFERENCES users(id), + value TEXT NOT NULL, + PRIMARY KEY (key, user_id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS settings_user_id_idx ON settings(user_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS specimen_type_container_type ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + specimen_type_id INTEGER NOT NULL REFERENCES specimen_type(id) ON DELETE CASCADE, + container_type TEXT NOT NULL CHECK (container_type IN ('paper', 'cryovial_tube', 'micronix_tube', 'static_well')), + created TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (specimen_type_id, container_type) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS container_type_unit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + container_type TEXT NOT NULL CHECK (container_type IN ('paper', 'cryovial_tube', 'micronix_tube', 'static_well')), + unit_id INTEGER NOT NULL REFERENCES unit(id) ON DELETE CASCADE, + created TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (container_type, unit_id) +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS container_derivation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_container_id INTEGER NOT NULL REFERENCES storage_container(id), + child_container_id INTEGER NOT NULL REFERENCES storage_container(id), + derivation_type TEXT NOT NULL, + derivation_date TEXT, + operator_id INTEGER REFERENCES users(id), + protocol TEXT, + notes TEXT, + properties TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + CHECK (parent_container_id != child_container_id), + UNIQUE (child_container_id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_container_derivation_parent ON container_derivation(parent_container_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_container_derivation_child ON container_derivation(child_container_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_container_derivation_type ON container_derivation(derivation_type);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS idx_container_derivation_date ON container_derivation(derivation_date);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS error_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + source TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + error_code TEXT, + stack TEXT, + context TEXT, + user_id INTEGER, + url TEXT, + user_agent TEXT, + resolved INTEGER NOT NULL DEFAULT 0, + resolved_at TEXT, + resolved_by INTEGER +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS error_logs_timestamp_idx ON error_logs(timestamp);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS error_logs_source_idx ON error_logs(source);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS error_logs_level_idx ON error_logs(level);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS error_logs_resolved_idx ON error_logs(resolved);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS qpcr_experiment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + template_format TEXT NOT NULL, + status TEXT NOT NULL, + standard_layout TEXT, + plate_barcode TEXT, + instrument_type TEXT, + created TEXT NOT NULL DEFAULT (datetime('now')), + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS qpcr_experiment_status_idx ON qpcr_experiment(status);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS qpcr_experiment_target ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + qpcr_experiment_id INTEGER NOT NULL REFERENCES qpcr_experiment(id) ON DELETE CASCADE, + target_name TEXT NOT NULL, + fluorophore TEXT, + reporter TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (qpcr_experiment_id, target_name) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS qpcr_experiment_target_experiment_idx ON qpcr_experiment_target(qpcr_experiment_id);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS qpcr_experiment_well ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + qpcr_experiment_id INTEGER NOT NULL REFERENCES qpcr_experiment(id) ON DELETE CASCADE, + well_position TEXT NOT NULL, + barcode TEXT, + storage_container_id INTEGER REFERENCES storage_container(id), + specimen_id INTEGER REFERENCES specimen(id), + content_type TEXT, + standard_density REAL, + cq REAL, + starting_quantity REAL, + cq_mean REAL, + raw_sample_name TEXT, + UNIQUE (qpcr_experiment_id, well_position) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS qpcr_experiment_well_experiment_idx ON qpcr_experiment_well(qpcr_experiment_id);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS qpcr_experiment_well_specimen_idx ON qpcr_experiment_well(specimen_id);--> statement-breakpoint +PRAGMA foreign_keys=ON; diff --git a/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts b/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts index b5a972c..0858704 100644 --- a/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts +++ b/packages/api/src/db/__tests__/initial-schema-snapshot.test.ts @@ -1,14 +1,95 @@ import { describe, it, expect, afterAll } from 'vitest' import { Database } from 'bun:sqlite' +import { readFileSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' import { is } from 'drizzle-orm' import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core' import * as schema from '../schema' import { applyInitialSchema } from '../apply-initial-schema' -import { CURRENT_SCHEMA_VERSION, getRecordedSchemaVersion } from '../schema-evolution' +import { splitSqlStatements } from '../migration-runner' +import { CURRENT_SCHEMA_VERSION, evolveOperationalSchema, getRecordedSchemaVersion } from '../schema-evolution' + +/** initial_schema.sql as of schema version 1, the first versioned snapshot. Never edit it. */ +const VERSION_1_SNAPSHOT = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'initial_schema_v1.sql') + +/** A version 1 install brought to the current version by the numbered migrations. */ +function openMigratedDatabase(): Database { + const sqlite = new Database(':memory:') + for (const statement of splitSqlStatements(readFileSync(VERSION_1_SNAPSHOT, 'utf-8'))) { + sqlite.exec(statement) + } + evolveOperationalSchema(sqlite) + return sqlite +} + +const normaliseSql = (sql: string | null) => sql?.replace(/\s+/g, ' ').replace(/ IF NOT EXISTS/i, '').trim() ?? null + +/** + * Table shape from PRAGMA output, normalised so that two databases with the same schema compare + * equal however they were built: columns and keys are sorted (ALTER TABLE ADD COLUMN appends), + * and SQLite's generated index names give way to their columns. + */ +function describeSchema(sqlite: Database) { + const tables = sqlite + .query<{ name: string }, []>( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, + ) + .all() + .map(({ name }) => name) + + return Object.fromEntries( + tables.map((table) => { + const columns = sqlite + .query<{ name: string; type: string; notnull: number; dflt_value: string | null; pk: number }, []>( + `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info('${table}')`, + ) + .all() + .map((column) => ({ ...column, type: column.type.toUpperCase() })) + .sort((a, b) => a.name.localeCompare(b.name)) + + const indexes = sqlite + .query<{ name: string; unique: number; origin: string; partial: number }, []>( + `SELECT name, "unique", origin, partial FROM pragma_index_list('${table}')`, + ) + .all() + .map((index) => { + const indexColumns = sqlite + .query<{ name: string | null }, []>(`SELECT name FROM pragma_index_info('${index.name}') ORDER BY seqno`) + .all() + .map((column) => column.name) + const generated = index.name.startsWith('sqlite_autoindex_') + const sql = sqlite + .query<{ sql: string | null }, [string]>(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`) + .get(index.name)?.sql + return { + name: generated ? `(${index.origin} on ${indexColumns.join(', ')})` : index.name, + unique: index.unique, + origin: index.origin, + partial: index.partial, + columns: indexColumns, + sql: normaliseSql(sql ?? null), + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) + + const foreignKeys = sqlite + .query< + { table: string; from: string; to: string | null; on_update: string; on_delete: string; match: string }, + [] + >(`SELECT "table", "from", "to", on_update, on_delete, "match" FROM pragma_foreign_key_list('${table}')`) + .all() + .sort((a, b) => `${a.from}>${a.table}`.localeCompare(`${b.from}>${b.table}`)) + + return [table, { columns, indexes, foreignKeys }] + }), + ) +} /** * New installs get initial_schema.sql and nothing else when its version is current, - * so the snapshot alone must hold every table and column in schema.ts. + * so the snapshot alone must hold every table and column in schema.ts, and must match + * what the numbered migrations build on top of an older install. */ describe('initial_schema.sql snapshot', () => { const sqlite = new Database(':memory:') @@ -47,4 +128,36 @@ describe('initial_schema.sql snapshot', () => { it('finds the schema.ts tables', () => { expect(drizzleTables.length).toBeGreaterThan(20) }) + + it('matches a version 1 install after the migrations: columns, types, NOT NULL, indexes, foreign keys', () => { + const migrated = openMigratedDatabase() + try { + expect(getRecordedSchemaVersion(migrated)).toBe(CURRENT_SCHEMA_VERSION) + // A difference means a migration and initial_schema.sql disagree (step 6 in src/db/README.md). + expect(describeSchema(sqlite)).toEqual(describeSchema(migrated)) + } finally { + migrated.close() + } + }) + + it('notices a snapshot that drifts from the migrations', () => { + const drifted = new Database(':memory:') + try { + applyInitialSchema(drifted) + drifted.exec('DROP INDEX sheet_box_name_idx') + drifted.exec('CREATE INDEX sheet_box_name_idx ON sheet(box_id, name) WHERE box_id IS NOT NULL') + drifted.exec('ALTER TABLE paper ADD COLUMN extra TEXT NOT NULL DEFAULT \'\'') + const migrated = openMigratedDatabase() + try { + const expected = describeSchema(migrated) + const actual = describeSchema(drifted) + expect(actual.sheet.indexes).not.toEqual(expected.sheet.indexes) + expect(actual.paper.columns).not.toEqual(expected.paper.columns) + } finally { + migrated.close() + } + } finally { + drifted.close() + } + }) }) From af31451606a9ad4bbe6417e89f86dd70b9cb3ff9 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:50:12 -0700 Subject: [PATCH 17/85] docs: make the root README the one place for coverage targets. The 90% target and what each threshold does were spread across README.md, packages/web/vitest.config.ts and the commented-out bunfig.toml threshold. The configs now point to a Coverage section; no config values change. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 11 +++++++++-- packages/api/bunfig.toml | 2 +- packages/api/vitest.config.ts | 1 + packages/web/vitest.config.ts | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 244a32f..0d79369 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,15 @@ Put local overrides in **`.env.local`** (gitignored) at the repo root. Bun’s a - **Unit tests (API + web)**: `bun run test` - **End-to-end (Playwright)**: `bun run test:e2e` -- **API**: `bun --filter @sampledb/api test` (Bun). Coverage: `bun --filter @sampledb/api test:coverage` (report in `packages/api/coverage/`). Target 90% lines. Test helpers: `packages/api/src/__tests__/README.md`. -- **Web**: `bun --filter @sampledb/web test` (Vitest). Coverage: `bun --filter @sampledb/web test:coverage`. Coverage excludes `src/lib/api/**` and `src/**/*.css` so the 90% target applies to testable code. Thresholds in `packages/web/vitest.config.ts` can be raised in steps toward 90%. Test helpers: `packages/web/src/__tests__/README.md`. +- **API**: `bun --filter @sampledb/api test` (Bun). Coverage: `bun --filter @sampledb/api test:coverage` (report in `packages/api/coverage/`). Test helpers: `packages/api/src/__tests__/README.md`. +- **Web**: `bun --filter @sampledb/web test` (Vitest). Coverage: `bun --filter @sampledb/web test:coverage`. Test helpers: `packages/web/src/__tests__/README.md`. + +### Coverage + +This is the one place coverage targets are written down; the config files point here. Both packages target 90% line coverage. + +- **API**: no threshold is enforced. `coverageThreshold` in `packages/api/bunfig.toml` is commented out; set it to make `test:coverage` fail below that line coverage. The thresholds in `packages/api/vitest.config.ts` apply only to Vitest coverage runs, and Vitest cannot run the tests that touch `bun:sqlite`. +- **Web**: the thresholds in `packages/web/vitest.config.ts` are a floor that fails `test:coverage`. Raise them in steps toward 90%. Coverage excludes `src/lib/api/**` and `src/**/*.css`, so the target applies to testable code. ## Database Configuration diff --git a/packages/api/bunfig.toml b/packages/api/bunfig.toml index 1332dd5..630ecf3 100644 --- a/packages/api/bunfig.toml +++ b/packages/api/bunfig.toml @@ -5,6 +5,6 @@ exclude = ["dist/**", "node_modules/**"] coverage = true coverageReporter = ["text", "lcov"] coverageDir = "coverage" -# Optional: set to fail the run below this line coverage. +# Optional: set to fail the run below this line coverage. Targets: Coverage in the root README.md. # coverageThreshold = 50 coveragePathIgnorePatterns = ["node_modules", "src/__tests__", "src/**/*.d.ts", "src/**/*.config.*", "dist"] diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index fb3e43d..e91696e 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ 'src/**/__tests__/**', 'dist/**', ], + // Applies only to Vitest coverage runs. Targets: Coverage in the root README.md. thresholds: { statements: 60, branches: 60, diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 0c64564..e238ca3 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ 'src/lib/api/**', 'dist/**', ], - // Baseline; raise in steps toward 90% as coverage improves. + // A floor, not the target. Targets: Coverage in the root README.md. thresholds: { statements: 35, branches: 26, From d40df05a393259bf99d0636bf88d16ee39d8f8b2 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:50:25 -0700 Subject: [PATCH 18/85] docs: restore the CURRENT_SCHEMA_VERSION bullet in the db README. It is the only place the README defines the CURRENT that the evolution table compares against, and support needs it to read a schema_version value. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/db/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api/src/db/README.md b/packages/api/src/db/README.md index b33f559..57b70a2 100644 --- a/packages/api/src/db/README.md +++ b/packages/api/src/db/README.md @@ -27,6 +27,7 @@ Migration `001` is **only** for legacy files; fresh installs from the snapshot s ## Schema version - Table: `schema_version` with a single row (`version INTEGER NOT NULL`). +- Constant: `CURRENT_SCHEMA_VERSION` in `schema-evolution.ts`, the version the code expects. "How evolution works" calls it `CURRENT`. - Inspect in support: `SELECT version FROM schema_version;` ## Opening a database From 31441fb025bb9118f45e10b003b95527ff0474d9 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:50:37 -0700 Subject: [PATCH 19/85] test(api): add collection and container subtype factories, and use them. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/__tests__/README.md | 3 + .../api/src/__tests__/helpers/factories.ts | 140 +++++++++++++++++- .../src/lib/__tests__/container-move.test.ts | 22 +-- .../src/lib/__tests__/derivations-csv.test.ts | 3 +- .../src/lib/__tests__/write-atomicity.test.ts | 19 ++- .../__tests__/location-filter.test.ts | 29 ++-- .../api/src/routes/__tests__/activity.test.ts | 9 +- .../src/routes/__tests__/containers.test.ts | 9 +- 8 files changed, 190 insertions(+), 44 deletions(-) diff --git a/packages/api/src/__tests__/README.md b/packages/api/src/__tests__/README.md index 39dd4f8..845c9fa 100644 --- a/packages/api/src/__tests__/README.md +++ b/packages/api/src/__tests__/README.md @@ -77,11 +77,14 @@ Available factories: - `createTestStrain` - `createTestStorageContainer` - `createTestLocation` +- `createTestMicronixPlate`, `createTestCryovialBox`, `createTestBox`, `createTestBag`, `createTestSheet` +- `createTestMicronixTube`, `createTestCryovialTube`, `createTestStaticWell`, `createTestPaper` (give an existing storage container its subtype row) - `createTestControlDefinition` - `createTestSpecimen` - `createTestUnit` - `createTestStudy` - `createTestStudySubject` +- `createTestControlBatch` ## Authenticated route tests diff --git a/packages/api/src/__tests__/helpers/factories.ts b/packages/api/src/__tests__/helpers/factories.ts index fe66c86..e5d7feb 100644 --- a/packages/api/src/__tests__/helpers/factories.ts +++ b/packages/api/src/__tests__/helpers/factories.ts @@ -1,5 +1,27 @@ import type { Database } from '../../db/client' -import { tag, storageType, specimenType, strain, storageContainer, location, controlDefinition, specimen, unit, study, studySubject, controlBatch, micronixPlate } from '../../db/schema' +import { + tag, + storageType, + specimenType, + strain, + storageContainer, + location, + controlDefinition, + specimen, + unit, + study, + studySubject, + controlBatch, + micronixPlate, + cryovialBox, + box, + bag, + sheet, + micronixTube, + cryovialTube, + staticWell, + paper, +} from '../../db/schema' import { utcNow } from '../../lib/datetime' /** @@ -130,6 +152,122 @@ export async function createTestMicronixPlate(db: Database, data: { return result } +/** + * Create a test cryovial box + */ +export async function createTestCryovialBox(db: Database, data: { + name: string + locationId: number + barcode?: string | null +}) { + const now = utcNow() + const [result] = await db.insert(cryovialBox).values({ + name: data.name, + locationId: data.locationId, + barcode: data.barcode ?? null, + created: now, + lastUpdated: now, + }).returning() + return result +} + +/** + * Create a test box (a paper Sheet parent) + */ +export async function createTestBox(db: Database, data: { name: string; locationId: number }) { + const now = utcNow() + const [result] = await db.insert(box).values({ ...data, created: now, lastUpdated: now }).returning() + return result +} + +/** + * Create a test bag (a paper Sheet parent) + */ +export async function createTestBag(db: Database, data: { name: string; locationId: number }) { + const now = utcNow() + const [result] = await db.insert(bag).values({ ...data, created: now, lastUpdated: now }).returning() + return result +} + +/** + * Create a test sheet in a box or a bag + */ +export async function createTestSheet( + db: Database, + data: { name: string } & ({ boxId: number; bagId?: never } | { bagId: number; boxId?: never }), +) { + const [result] = await db.insert(sheet).values(data).returning() + return result +} + +/** + * Make an existing storage container a micronix tube on a plate + */ +export async function createTestMicronixTube(db: Database, data: { + containerId: number + collectionId: number + barcode: string + position?: string | null +}) { + const [result] = await db.insert(micronixTube).values({ + id: data.containerId, + collectionId: data.collectionId, + barcode: data.barcode, + position: data.position ?? null, + }).returning() + return result +} + +/** + * Make an existing storage container a cryovial tube in a cryovial box + */ +export async function createTestCryovialTube(db: Database, data: { + containerId: number + collectionId: number + barcode?: string | null + position?: string | null +}) { + const [result] = await db.insert(cryovialTube).values({ + id: data.containerId, + collectionId: data.collectionId, + barcode: data.barcode ?? null, + position: data.position ?? null, + }).returning() + return result +} + +/** + * Make an existing storage container a static well on a plate + */ +export async function createTestStaticWell(db: Database, data: { + containerId: number + collectionId: number + position?: string | null +}) { + const [result] = await db.insert(staticWell).values({ + id: data.containerId, + collectionId: data.collectionId, + position: data.position ?? null, + }).returning() + return result +} + +/** + * Make an existing storage container a paper on a sheet + */ +export async function createTestPaper(db: Database, data: { + containerId: number + sheetId: number + sublabel?: string | null +}) { + const [result] = await db.insert(paper).values({ + id: data.containerId, + sheetId: data.sheetId, + sublabel: data.sublabel ?? null, + }).returning() + return result +} + /** * Create a test location (for testing storage type "in use") */ diff --git a/packages/api/src/lib/__tests__/container-move.test.ts b/packages/api/src/lib/__tests__/container-move.test.ts index 02f77c2..dd4f566 100644 --- a/packages/api/src/lib/__tests__/container-move.test.ts +++ b/packages/api/src/lib/__tests__/container-move.test.ts @@ -7,6 +7,10 @@ import { createTestSpecimenType, createTestSpecimen, createTestUnit, + createTestBox, + createTestSheet, + createTestStorageContainer, + createTestPaper, } from '../../__tests__/helpers/factories' import { resolveContainerByPosition, @@ -17,7 +21,7 @@ import { type ContainerInfo, } from '../container-move' import { storageContainer } from '../../db/schema' -import { micronixTube, staticWell, box, sheet, paper } from '../../db/schema' +import { micronixTube, staticWell, paper } from '../../db/schema' import type { Database } from '../../db/client' import { utcNow } from '../datetime' @@ -568,16 +572,12 @@ describe('container-move', () => { const specimenType = await createTestSpecimenType(testDb, { name: 'Blood' }) const specimen = await createTestSpecimen(testDb, specimenType.id) const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' }) - const now = utcNow() - const [boxA] = await testDb.insert(box).values({ name: 'BA', locationId: location.id, created: now, lastUpdated: now }).returning() - const [boxB] = await testDb.insert(box).values({ name: 'BB', locationId: location.id, created: now, lastUpdated: now }).returning() - const [sheetA] = await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxA.id }).returning() - const [sheetB] = await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxB.id }).returning() - const [container] = await testDb - .insert(storageContainer) - .values({ specimenId: specimen.id, unitId: unit.id, totalQuantity: 1, remainingQuantity: 1, created: now, lastUpdated: now }) - .returning() - await testDb.insert(paper).values({ id: container!.id, sheetId: sheetB.id }) + const boxA = await createTestBox(testDb, { name: 'BA', locationId: location.id }) + const boxB = await createTestBox(testDb, { name: 'BB', locationId: location.id }) + const sheetA = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxA.id }) + const sheetB = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxB.id }) + const container = await createTestStorageContainer(testDb, { specimenId: specimen.id, unitId: unit.id }) + await createTestPaper(testDb, { containerId: container.id, sheetId: sheetB.id }) const ambiguous = await executeMoves(testDb, { mappings: [{ fromCollectionName: 'Sheet-1', toCollectionName: 'Sheet-1' }], diff --git a/packages/api/src/lib/__tests__/derivations-csv.test.ts b/packages/api/src/lib/__tests__/derivations-csv.test.ts index 1c28a94..3c03669 100644 --- a/packages/api/src/lib/__tests__/derivations-csv.test.ts +++ b/packages/api/src/lib/__tests__/derivations-csv.test.ts @@ -20,6 +20,7 @@ import { createTestStorageType, createTestLocation, createTestMicronixPlate, + createTestMicronixTube, } from '../../__tests__/helpers/factories' import { setContainerDefaults } from '../settings' import { utcNow } from '../datetime' @@ -338,7 +339,7 @@ describe('derivations-csv', () => { .insert(storageContainer) .values({ specimenId: parentSpecimen.id, unitId: unit.id, totalQuantity: quantity, remainingQuantity: quantity, created: now, lastUpdated: now }) .returning() - await testDb.insert(micronixTube).values({ id: sc.id, collectionId: plate.id, position, barcode }) + await createTestMicronixTube(testDb, { containerId: sc.id, collectionId: plate.id, position, barcode }) return sc } const parent = await addTube('MT-PARENT', 'A01', 10) diff --git a/packages/api/src/lib/__tests__/write-atomicity.test.ts b/packages/api/src/lib/__tests__/write-atomicity.test.ts index 97a5524..7e549d2 100644 --- a/packages/api/src/lib/__tests__/write-atomicity.test.ts +++ b/packages/api/src/lib/__tests__/write-atomicity.test.ts @@ -7,6 +7,8 @@ import { createTestLocation, createTestMicronixPlate, createTestUnit, + createTestBox, + createTestSheet, } from '../../__tests__/helpers/factories' import { setContainerDefaults } from '../settings' import { runBulkCombinedImport } from '../bulk-combined-import' @@ -17,7 +19,6 @@ import { studySubject, specimen, storageContainer, - box, sheet, } from '../../db/schema' import { eq } from 'drizzle-orm' @@ -104,10 +105,9 @@ describe('multi-step writes roll back on failure', () => { it('sheet move leaves every sheet in place when one sheet id is missing', async () => { const { loc } = await setupMicronixPlate() - const now = utcNow() - const [boxA] = await testDb.insert(box).values({ name: 'BoxA', locationId: loc.id, created: now, lastUpdated: now }).returning() - const [boxB] = await testDb.insert(box).values({ name: 'BoxB', locationId: loc.id, created: now, lastUpdated: now }).returning() - const [s1] = await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxA.id }).returning() + const boxA = await createTestBox(testDb, { name: 'BoxA', locationId: loc.id }) + const boxB = await createTestBox(testDb, { name: 'BoxB', locationId: loc.id }) + const s1 = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxA.id }) await expect(moveSheetsToCollection(testDb, [s1.id, 99999], boxB.id, 'box')).rejects.toBeInstanceOf( SheetNotFoundError @@ -119,11 +119,10 @@ describe('multi-step writes roll back on failure', () => { it('sheet move refuses a name that already exists in the target box', async () => { const { loc } = await setupMicronixPlate() - const now = utcNow() - const [boxA] = await testDb.insert(box).values({ name: 'BoxA', locationId: loc.id, created: now, lastUpdated: now }).returning() - const [boxB] = await testDb.insert(box).values({ name: 'BoxB', locationId: loc.id, created: now, lastUpdated: now }).returning() - const [moving] = await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxA.id }).returning() - await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxB.id }) + const boxA = await createTestBox(testDb, { name: 'BoxA', locationId: loc.id }) + const boxB = await createTestBox(testDb, { name: 'BoxB', locationId: loc.id }) + const moving = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxA.id }) + await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxB.id }) await expect(moveSheetsToCollection(testDb, [moving.id], boxB.id, 'box')).rejects.toBeInstanceOf(SheetNameConflictError) }) diff --git a/packages/api/src/lib/statistics/__tests__/location-filter.test.ts b/packages/api/src/lib/statistics/__tests__/location-filter.test.ts index 037adde..f7f52bb 100644 --- a/packages/api/src/lib/statistics/__tests__/location-filter.test.ts +++ b/packages/api/src/lib/statistics/__tests__/location-filter.test.ts @@ -3,13 +3,17 @@ import type { Database } from '../../../db/client' import type { Database as SQLiteDatabase } from 'bun:sqlite' import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' import { + createTestBag, + createTestBox, createTestLocation, createTestMicronixPlate, + createTestMicronixTube, + createTestPaper, + createTestSheet, + createTestStaticWell, createTestStorageContainer, createTestStorageType, } from '../../../__tests__/helpers/factories' -import { micronixTube, staticWell, box, bag, sheet, paper } from '../../../db/schema' -import { utcNow } from '../../datetime' import { resolveContainerIdsAtLocations, resolveStatisticsLocationFilter, @@ -83,8 +87,8 @@ describe('location-filter', () => { await createTestMicronixPlate(testDb, { name: 'Plate 2', locationId: otherLoc.id }) const container = await createTestStorageContainer(testDb) - await testDb.insert(micronixTube).values({ - id: container.id, + await createTestMicronixTube(testDb, { + containerId: container.id, collectionId: plate.id, barcode: 'MX-LOC-1', position: 'A01', @@ -98,21 +102,18 @@ describe('location-filter', () => { it('resolveContainerIdsAtLocations includes static wells and papers in boxes and bags', async () => { const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) const loc = await createTestLocation(testDb, { name: 'Freezer A', storageTypeId: String(storageType.id) }) - const now = utcNow() const plate = await createTestMicronixPlate(testDb, { name: 'P1', locationId: loc.id }) const well = await createTestStorageContainer(testDb) - await testDb.insert(staticWell).values({ id: well.id, collectionId: plate.id, position: 'A01' }) - const [boxRow] = await testDb.insert(box).values({ name: 'Box1', locationId: loc.id, created: now, lastUpdated: now }).returning() - const [bagRow] = await testDb.insert(bag).values({ name: 'Bag1', locationId: loc.id, created: now, lastUpdated: now }).returning() - const [boxSheet] = await testDb.insert(sheet).values({ name: 'S1', boxId: boxRow.id }).returning() - const [bagSheet] = await testDb.insert(sheet).values({ name: 'S1', bagId: bagRow.id }).returning() + await createTestStaticWell(testDb, { containerId: well.id, collectionId: plate.id, position: 'A01' }) + const boxRow = await createTestBox(testDb, { name: 'Box1', locationId: loc.id }) + const bagRow = await createTestBag(testDb, { name: 'Bag1', locationId: loc.id }) + const boxSheet = await createTestSheet(testDb, { name: 'S1', boxId: boxRow.id }) + const bagSheet = await createTestSheet(testDb, { name: 'S1', bagId: bagRow.id }) const reuse = { specimenId: well.specimenId, unitId: well.unitId! } const paperInBox = await createTestStorageContainer(testDb, reuse) const paperInBag = await createTestStorageContainer(testDb, reuse) - await testDb.insert(paper).values([ - { id: paperInBox.id, sheetId: boxSheet.id }, - { id: paperInBag.id, sheetId: bagSheet.id }, - ]) + await createTestPaper(testDb, { containerId: paperInBox.id, sheetId: boxSheet.id }) + await createTestPaper(testDb, { containerId: paperInBag.id, sheetId: bagSheet.id }) const ids = await resolveContainerIdsAtLocations(testDb, [loc.id]) diff --git a/packages/api/src/routes/__tests__/activity.test.ts b/packages/api/src/routes/__tests__/activity.test.ts index fb975d2..28ece91 100644 --- a/packages/api/src/routes/__tests__/activity.test.ts +++ b/packages/api/src/routes/__tests__/activity.test.ts @@ -15,8 +15,8 @@ import { createTestStorageType, createTestLocation, createTestMicronixPlate, + createTestMicronixTube, } from '../../__tests__/helpers/factories' -import { micronixTube } from '../../db/schema' import { createActivityRoutes } from '../activity' describe('Activity API', () => { @@ -98,7 +98,12 @@ describe('Activity API', () => { const storageType = await createTestStorageType(ctx.db, { name: 'Freezer' }) const loc = await createTestLocation(ctx.db, { name: 'Loc', storageTypeId: String(storageType.id) }) const plate = await createTestMicronixPlate(ctx.db, { name: 'P1', locationId: loc.id }) - await ctx.db.insert(micronixTube).values({ id: container.id, collectionId: plate.id, barcode: 'MT-9', position: 'A01' }) + await createTestMicronixTube(ctx.db, { + containerId: container.id, + collectionId: plate.id, + barcode: 'MT-9', + position: 'A01', + }) const res = await ctx.request('/api/activity/recent', { method: 'GET' }) const data = (await res.json()) as { activity: Array<{ id: number; type: string; label: string; context?: string }> } diff --git a/packages/api/src/routes/__tests__/containers.test.ts b/packages/api/src/routes/__tests__/containers.test.ts index b9bf89d..ead182c 100644 --- a/packages/api/src/routes/__tests__/containers.test.ts +++ b/packages/api/src/routes/__tests__/containers.test.ts @@ -16,6 +16,8 @@ import { createTestLocation, createTestUnit, createTestStorageType, + createTestCryovialBox, + createTestCryovialTube, } from '../../__tests__/helpers/factories' import { cryovialBox, cryovialTube, specimen, storageContainer, micronixPlate, micronixTube, box, sheet, paper, staticWell } from '../../db/schema' import { eq } from 'drizzle-orm' @@ -203,10 +205,7 @@ describe('Containers API', () => { it('clears the comment when it is emptied', async () => { const now = utcNow() - const [boxRecord] = await ctx.db - .insert(cryovialBox) - .values({ name: `BOX-CMT-${Date.now()}`, locationId: testLocation.id, created: now, lastUpdated: now }) - .returning() + const boxRecord = await createTestCryovialBox(ctx.db, { name: `BOX-CMT-${Date.now()}`, locationId: testLocation.id }) const testStudy = await createTestStudy(ctx.db, { title: 'C Study', shortCode: 'CST' + Date.now() }) const subj = await createTestStudySubject(ctx.db, { studyId: testStudy.id, name: 'S1' }) const [spec] = await ctx.db @@ -217,7 +216,7 @@ describe('Containers API', () => { .insert(storageContainer) .values({ specimenId: spec.id, unitId: testUnit.id, totalQuantity: 1, remainingQuantity: 1, comment: 'hemolyzed', created: now, lastUpdated: now }) .returning() - await ctx.db.insert(cryovialTube).values({ id: container.id, collectionId: boxRecord.id, barcode: 'CMT-1', position: 'A01' }) + await createTestCryovialTube(ctx.db, { containerId: container.id, collectionId: boxRecord.id, barcode: 'CMT-1', position: 'A01' }) const res = await ctx.request(`/api/containers/${container.id}`, { method: 'PATCH', json: { comment: '' } }) From ad2452540748391a6d45041b8eeb8299710530c3 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:51:14 -0700 Subject: [PATCH 20/85] refactor(api): bundle a derivation row's child collection name and paper parent type. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/__tests__/derivations-csv.test.ts | 22 ++++++- packages/api/src/lib/derivations-csv.ts | 60 +++++++++---------- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/packages/api/src/lib/__tests__/derivations-csv.test.ts b/packages/api/src/lib/__tests__/derivations-csv.test.ts index 3c03669..ee9a6d9 100644 --- a/packages/api/src/lib/__tests__/derivations-csv.test.ts +++ b/packages/api/src/lib/__tests__/derivations-csv.test.ts @@ -21,6 +21,7 @@ import { createTestLocation, createTestMicronixPlate, createTestMicronixTube, + createTestBag, } from '../../__tests__/helpers/factories' import { setContainerDefaults } from '../settings' import { utcNow } from '../datetime' @@ -344,7 +345,7 @@ describe('derivations-csv', () => { } const parent = await addTube('MT-PARENT', 'A01', 10) await addTube('MT-TAKEN', 'A02', 1) - return { parent } + return { parent, loc } } it('checks non-padded positions against stored ones and against each other', async () => { @@ -390,5 +391,24 @@ describe('derivations-csv', () => { expect(result.rows[0].valid).toBe(false) expect(result.rows[0].error).toMatch(/Box 'No Such Box' not found/) }) + + it('finds the bag named by bag_name for paper rows', async () => { + const { parent, loc } = await seedParent() + await createTestBag(testDb, { name: 'Shared Name', locationId: loc.id }) + const csv = ['parent_container_id,container_type,bag_name,sheet_name,sublabel', `${parent.id},paper,Shared Name,Sheet-1,S1`].join('\n') + + const result = await validateDerivationsCsv(testDb, csv, { ...settings, containerType: '' }) + + expect(result.rows[0]).toMatchObject({ valid: true, collectionStatus: 'existing' }) + }) + + it('reports a paper row with neither box_name nor bag_name as a missing field', async () => { + const { parent } = await seedParent() + const csv = ['parent_container_id,container_type,box_name,sheet_name,sublabel', `${parent.id},paper,,Sheet-1,S1`].join('\n') + + const result = await validateDerivationsCsv(testDb, csv, { ...settings, containerType: '' }) + + expect(result.rows[0].error).toBe('box_name or bag_name is required for paper') + }) }) }) diff --git a/packages/api/src/lib/derivations-csv.ts b/packages/api/src/lib/derivations-csv.ts index dd73588..a9d8184 100644 --- a/packages/api/src/lib/derivations-csv.ts +++ b/packages/api/src/lib/derivations-csv.ts @@ -205,7 +205,7 @@ function buildParentSummary(row: DerivationCsvRow): string { /** Build a short user-facing label for the child placement (no internal IDs). */ function buildChildSummary(row: DerivationCsvRow): string { const containerType = row.container_type || 'micronix_tube' - const name = (getCollectionNameForType(row, containerType) ?? '').trim() + const name = (childCollectionRef(row, containerType).name ?? '').trim() const barcode = (row.collection_barcode ?? '').trim() const pos = (row.position ?? '').trim() const parts: string[] = [] @@ -223,19 +223,30 @@ function buildChildSummary(row: DerivationCsvRow): string { return 'Child' } -/** Paper rows name their parent with box_name or bag_name. */ -function paperParentTypeFor(row: DerivationCsvRow): 'box' | 'bag' { - return row.box_name ? 'box' : row.bag_name ? 'bag' : 'box' +/** + * The collection a row's child container goes into, as the CSV names it: the plate or + * cryovial box for tubes, or the box or bag holding the sheet for paper. + */ +type ChildCollectionRef = { + name?: string + barcode?: string + /** Paper only: whether `name` is a box or a bag. */ + paperParentType?: 'box' | 'bag' } -function getCollectionNameForType( +function childCollectionRef( row: DerivationCsvRow, containerType: DerivationCsvRow['container_type'] | 'micronix_tube', -): string | undefined { +): ChildCollectionRef { // Column names align with csvRowToContainerWriteInput (contract write CSV mapper). - if (containerType === 'cryovial_tube') return row.box_name - if (containerType === 'paper') return row.box_name || row.bag_name - return row.plate_name + const barcode = row.collection_barcode + if (containerType === 'cryovial_tube') return { name: row.box_name, barcode } + if (containerType === 'paper') { + return row.box_name || !row.bag_name + ? { name: row.box_name, barcode, paperParentType: 'box' } + : { name: row.bag_name, barcode, paperParentType: 'bag' } + } + return { name: row.plate_name, barcode } } function getCollectionNameLabelForType( @@ -297,9 +308,7 @@ export function parseCsv(text: string): DerivationCsvRow[] { async function resolveCollectionId( database: DatabaseOrTransaction, containerType: DerivationCsvRow['container_type'], - collectionName?: string, - collectionBarcode?: string, - paperParentType: 'box' | 'bag' = 'box', + { name: collectionName, barcode: collectionBarcode, paperParentType = 'box' }: ChildCollectionRef, ): Promise<{ id?: number; status: 'existing' | 'will_be_created' }> { if (!collectionName && !collectionBarcode) { return { status: 'will_be_created' } @@ -427,7 +436,8 @@ export async function validateDerivationsCsv( } const containerType = row.container_type || settings?.containerType || 'micronix_tube' - const collectionName = getCollectionNameForType(row, containerType) + const childCollection = childCollectionRef(row, containerType) + const collectionName = childCollection.name if ((containerType === 'micronix_tube' || containerType === 'cryovial_tube') && !collectionName && !row.collection_barcode) { validationRow.error = `${getCollectionNameLabelForType(containerType)} or collection_barcode is required for ${containerType} derivations` validationRows.push(validationRow) @@ -504,18 +514,12 @@ export async function validateDerivationsCsv( } // Resolve collection and track status - const collectionInfo = await resolveCollectionId( - database, - containerType, - collectionName, - row.collection_barcode, - paperParentTypeFor(row), - ) + const collectionInfo = await resolveCollectionId(database, containerType, childCollection) validationRow.collectionStatus = collectionInfo.status if (containerType === 'paper' && collectionInfo.status !== 'existing') { - const kind = paperParentTypeFor(row) - validationRow.error = `${kind === 'bag' ? 'Bag' : 'Box'} '${collectionName ?? ''}' not found. Create it before importing paper derivations.` + const kind = childCollection.paperParentType === 'bag' ? 'Bag' : 'Box' + validationRow.error = `${kind} '${collectionName ?? ''}' not found. Create it before importing paper derivations.` validationRows.push(validationRow) invalidCount++ continue @@ -704,9 +708,7 @@ export async function importDerivationsFromCsv( const collectionInfo = await resolveCollectionId( tx, childContainerType, - getCollectionNameForType(row, childContainerType), - row.collection_barcode, - paperParentTypeFor(row), + childCollectionRef(row, childContainerType), ) const input: CreateDerivationInput = { @@ -793,13 +795,7 @@ export async function importDerivationsFromCsv( }) continue } - const collectionInfo = await resolveCollectionId( - database, - containerType, - getCollectionNameForType(row, containerType), - row.collection_barcode, - paperParentTypeFor(row), - ) + const collectionInfo = await resolveCollectionId(database, containerType, childCollectionRef(row, containerType)) results.push({ index: i, From 1b26092c6c8392856209d228653958e6e1ab8061 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:51:34 -0700 Subject: [PATCH 21/85] test(api): pin the derivation source-type warning when row 1 has no parent. The comparison against the first row whose parent resolves (71c1919) had no test: with the old `i === 0` check, an invalid first row switched the warning off for the whole file. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/__tests__/derivations-csv.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/api/src/lib/__tests__/derivations-csv.test.ts b/packages/api/src/lib/__tests__/derivations-csv.test.ts index ee9a6d9..5aef2c8 100644 --- a/packages/api/src/lib/__tests__/derivations-csv.test.ts +++ b/packages/api/src/lib/__tests__/derivations-csv.test.ts @@ -22,6 +22,8 @@ import { createTestMicronixPlate, createTestMicronixTube, createTestBag, + createTestSpecimen, + createTestStorageContainer, } from '../../__tests__/helpers/factories' import { setContainerDefaults } from '../settings' import { utcNow } from '../datetime' @@ -345,7 +347,7 @@ describe('derivations-csv', () => { } const parent = await addTube('MT-PARENT', 'A01', 10) await addTube('MT-TAKEN', 'A02', 1) - return { parent, loc } + return { parent, loc, plate, unit } } it('checks non-padded positions against stored ones and against each other', async () => { @@ -359,6 +361,21 @@ describe('derivations-csv', () => { expect(result.rows[2].error).toMatch(/more than once in your file/) }) + it('warns about a mismatched source type even when the first row has no parent', async () => { + const { parent, plate, unit } = await seedParent() + const plasmaType = await createTestSpecimenType(testDb, { name: 'Plasma' }) + const plasma = await createTestSpecimen(testDb, plasmaType.id) + const plasmaTube = await createTestStorageContainer(testDb, { specimenId: plasma.id, unitId: unit.id }) + await createTestMicronixTube(testDb, { containerId: plasmaTube.id, collectionId: plate.id, barcode: 'MT-PLASMA', position: 'H12' }) + const csv = [header, '99999,PlateDef,B01,C0', `${parent.id},PlateDef,B02,C1`, `${plasmaTube.id},PlateDef,B03,C2`].join('\n') + + const result = await validateDerivationsCsv(testDb, csv, { ...settings, validateSourceSpecimenType: true }) + + expect(result.rows[0].valid).toBe(false) + expect(result.rows[1].warnings).toBeUndefined() + expect(result.rows[2].warnings).toContain('Source specimen type does not match other rows') + }) + it('reports a quantity that is not a number instead of dropping it', async () => { const { parent } = await seedParent() const csv = [`${header},quantity_used`, `${parent.id},PlateDef,B01,C1,3x`].join('\n') From 0f2c60531e806183aad2fe7f0d394ca9355e8e1b Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:51:48 -0700 Subject: [PATCH 22/85] docs: scope the ADR rules to new code and match each ADR. Each rule now applies to new or changed code, keeps its ADR's strength, scope and exemptions, and names the paths still migrating. Adds the missing rules from ADRs 0001, 0004, 0005, 0006 and 0008. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 7d953e1..0117da6 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -22,16 +22,26 @@ Paths are relative to `packages/api/`. ### Domain modules -- Create a Specimen or Container through `registerSpecimens` in `src/lib/specimen-registration/`. Validate a request with `checkSpecimenRegistration`, which is the same write rolled back (ADR 0010). -- A request that carries a container validates it with `containerWriteInputSchema` from `@sampledb/contract` (ADR 0006). -- Resolve a Specimen's Source with `resolveSpecimenSource` or `resolveSpecimenSources` from `src/lib/specimens/provenance.ts`, and hand every caller the full `SpecimenSource` shape (ADR 0007). -- Build a container payload (placement, Tags, Specimen, Source) from `loadContainerReadViews` in `src/lib/container-read-view.ts`, then project it (ADR 0009). +- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import and `/subjects/with-specimens` have migrated. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, add-container and `/specimens/bulk`, which validate with `validateSpecimenData` or `validateBulkSpecimenRows` (`src/routes/specimens.ts`, `src/lib/registration-orchestrator.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). +- In a new write path, validate a registration with `checkSpecimenRegistration`, or wrap the whole write in `withRolledBackTransaction` when the caller writes its own records first (ADR 0010). +- Validate an inbound container with a `@sampledb/contract` schema (`containerWriteInputSchema`, `bulkCombinedContainerSchema` or `optionalContainerInputSchema`), not an endpoint-specific container Zod schema (ADR 0006). +- Map a CSV row to `ContainerWriteInput` with `csvRowToContainerWriteInput` from `@sampledb/contract` (ADR 0006). The web bulk import and control-batch CSVs still map through `flatBulkContainerToWriteInput` and `flatControlBatchContainerToWriteInput` in `packages/web/src/lib/`. +- In a `ContainerWriteInput`, place a paper container on a `collection` of type `sheet`, not on a box or bag `collection` with a sibling `sheetName` (ADR 0006). +- Resolve a Specimen's Source with `resolveSpecimenSource` or `resolveSpecimenSources` from `src/lib/specimens/provenance.ts` (ADR 0007). +- Return the full `SpecimenSource` shape to every caller, not a per-endpoint subset (ADR 0007). +- Load a container read payload (placement, Tags, Specimen, Source) with `loadContainerReadViews` or `loadContainerReadViewsByIds` from `src/lib/container-read-view.ts` (ADR 0009). +- Project the loaded view to its read path's type: `EnrichedContainerApi` for list, derivations and PATCH, `EnrichedStorageContainer` for collection detail, and CSV row fields for Container export (ADR 0009). ### Routes -- A route maps Drizzle rows to a wire DTO before `c.json` wherever the shape matters. `omitOnWireMiddleware` drops null keys from every response, so null omission stays with it (ADR 0005). -- A route reports an error by throwing a typed error from `src/lib/error-handler.ts` or returning `handleRouteError(error, c)` (ADR 0004). -- Structured stdout goes through `src/lib/observability.ts`. A failure meant for Admin Error Logs goes through `src/lib/error-logger.ts` (ADR 0004). +- In new or changed route code, report an error by throwing a typed error from `src/lib/error-handler.ts` or returning `handleRouteError(error, c)`, not with `return c.json({ error }, 4xx)` (ADR 0004). +- Throw `ExpectedNotFoundError` for a 404 caused by user input, such as an export preflight miss or a user-supplied lookup, and `NotFoundError` for a missing resource on a direct GET or a stale link (ADR 0004). +- Write no route-level `catch` block that calls `console.error`; return `handleRouteError(error, c)` from it instead (ADR 0004). +- In new code, write log lines to stdout with `logDebug`, `logInfo`, `logWarn` or `logError` from `src/lib/observability.ts`, not `console.*`. Startup banners and schema-evolution CLI output stay plain `console.log` (ADR 0004). The `console.warn` in `logTemplateError` in `src/routes/qpcr-experiments.ts` predates this rule. +- Persist a failure to the `error_logs` table with `src/lib/error-logger.ts`, not `observability.ts`. Both export `logError`, so check the import path (ADR 0004). +- In a new or changed route handler, do not spread a Drizzle row into a `c.json` payload (ADR 0005). `...newSpecimen` in `src/routes/specimens.ts` and `...locationRecord` in `src/routes/locations.ts` predate this rule. +- Map a response that has a schema in `@sampledb/contract/wire` (containers today) to that wire DTO before `c.json` (ADR 0005). +- Leave null omission to `omitOnWireMiddleware`: do not call `toWireJson()` in a route handler (ADR 0005). ### API tests @@ -45,11 +55,15 @@ Paths are relative to `packages/api/`. Paths are relative to `packages/web/`. - Call the API through its domain module, `src/lib/api/`. Its methods resolve to the response body, so read `res.study`. Reference-data `list()` helpers are the exception: their body is `{ data, meta? }` (ADR 0002). -- Read and write settings with `settingsApi.getValue` and `settingsApi.putValue` (ADR 0002). -- A Zod schema for a response marks an unset field `.optional()`, because the wire omits it (ADR 0005). -- Show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`. Toasts are for mutation errors (ADR 0001). -- A mutation goes through a domain hook that invalidates every query it affects, `dashboardKeys` included (ADR 0001). -- Scan move behaviour lands in the `src/lib/scan-move/` core with plain-data unit tests. The move pages only wire it up (ADR 0008). +- Read a setting with `settingsApi.getValue(key)` or `settingsApi.get(key).value`, never `api.get` on `/settings/:key` (ADR 0002). +- Write a setting with `settingsApi.putValue`, `settingsApi.update`, or the bulk wrappers `exportConfigurationsApi.update`, `scannerConfigurationsApi.update` and `tableViewConfigurationsApi.update`, never `api.put` on `/settings/:key` (ADR 0002). +- In a Zod parser for a container response, mark an unset field `.optional()`, not `.nullish()` (ADR 0005). Other response parsers are out of scope, such as `parseSettingsEnvelope` in `src/lib/api/parse-response.ts`. +- On a screen ADR 0001 lists as migrated, show a query's read error inline with `PageError` or `SectionMessage` from `src/ui/`, not a toast (ADR 0001). Workflow local state (CSV parse, server validate, submits) keeps its inline or toast errors. +- Prefer to send a new mutation through a domain hook, such as `useCreateStudy`, that invalidates the queries the mutation changes, including `dashboardKeys` when it changes dashboard data (ADR 0001). Direct `*Api.create`, `update` and `delete` calls in `Locations.tsx`, `QpcrExperimentDetail.tsx`, `StudyDetail.tsx`, `SubjectForm.tsx` and `ControlDefinitionForm.tsx` predate this rule. +- Put new Scan move behaviour in the `src/lib/scan-move/` core with plain-data unit tests, not in `ContainerMoveMicronix.tsx` or `ContainerMoveCryovial.tsx` (ADR 0008). The paper move page, `ContainerMovePapers.tsx`, is not a Scan move. +- Pass a Scan move effect a `ScanMoveGateway` instead of importing `collectionsApi` into it (ADR 0008). +- Test Scan move effects with plain stub gateway functions, not `vi.mock` (ADR 0008). +- Open a modal with `Modal` from `src/ui/`, not `ModalPortal` (ADR 0001). `CommandPalette` and `SearchModal` still use `ModalPortal` directly. ### Web tests From 3382d49af529da05a9716a19f92a5726d03baafe Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:51:54 -0700 Subject: [PATCH 23/85] docs: describe formula neutralization in CONTEXT.md's CSV download entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index aac7aed..a88ea33 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -166,8 +166,9 @@ Server-side flat export of inventory summary data (command palette or inventory. _Avoid_: Container export; bulk export; collection table snapshot export **CSV download**: -A comma-separated values file generated or downloaded from SampleDB — container/specimen/inventory exports, collection table snapshots, import templates, and other CSV outputs. All share the same wire conventions (UTF-8 with BOM, CRLF line endings, plain cell values per RFC 4180) so files behave consistently in Excel and in R/Python. **Container export** workflows additionally let lab staff override delimiter, BOM, and line ending before download. -_Avoid_: Excel formula cell wrappers (`="..."`); using CSV as the Excel text-preservation format (use **XLSX download** for that); different wire conventions for templates vs exports +A comma-separated values file generated or downloaded from SampleDB — container/specimen/inventory exports, collection table snapshots, import templates, and other CSV outputs. All share the same wire conventions (UTF-8 with BOM, CRLF line endings, cell values quoted per RFC 4180) so files behave consistently in Excel and in R/Python. **Container export** workflows additionally let lab staff override delimiter, BOM, and line ending before download. +Server-side data exports (**Container export**, **Specimen export**, **Inventory export**) apply **formula neutralization**: a text cell that a spreadsheet would run as a formula (starting with `=`, `+`, `@`, tab, carriage return, or `-` unless the whole cell is a number) gets a leading `'`, so stored text such as `=HYPERLINK(...)` cannot execute when the file is opened. Numbers, including negative ones like `-131`, are unchanged. Import templates and other files meant to be re-imported are not neutralized, since the `'` would become part of the imported value. +_Avoid_: Excel formula cell wrappers (`="..."`); using CSV as the Excel text-preservation format (use **XLSX download** for that); different wire conventions for templates vs exports; neutralizing import templates **XLSX download**: Excel workbook export from **Container export** workflows. Preserves identifier and code columns as text cells (leading zeros, long barcodes) without CSV workarounds. From 14eb9fdab1a4b985d7fb9a2d443d5d030c82f9de Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:52:25 -0700 Subject: [PATCH 24/85] chore(api): lint any transaction callback that returns a Promise, in tests too. The selector only caught async function literals written inline. A typed local rule also catches a named async function and a sync callback that returns a Promise. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../eslint-rules/__tests__/fixtures/file.ts | 1 + .../__tests__/fixtures/tsconfig.json | 4 ++ .../no-promise-transaction-callback.test.ts | 46 +++++++++++++ .../no-promise-transaction-callback.mjs | 67 +++++++++++++++++++ packages/api/eslint.config.mjs | 41 +++++++----- packages/api/package.json | 8 +-- packages/api/src/db/README.md | 2 +- packages/api/tsconfig.eslint.json | 6 ++ 8 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 packages/api/eslint-rules/__tests__/fixtures/file.ts create mode 100644 packages/api/eslint-rules/__tests__/fixtures/tsconfig.json create mode 100644 packages/api/eslint-rules/__tests__/no-promise-transaction-callback.test.ts create mode 100644 packages/api/eslint-rules/no-promise-transaction-callback.mjs create mode 100644 packages/api/tsconfig.eslint.json diff --git a/packages/api/eslint-rules/__tests__/fixtures/file.ts b/packages/api/eslint-rules/__tests__/fixtures/file.ts new file mode 100644 index 0000000..556f134 --- /dev/null +++ b/packages/api/eslint-rules/__tests__/fixtures/file.ts @@ -0,0 +1 @@ +// Placeholder so the fixture tsconfig has a file; RuleTester supplies the code. diff --git a/packages/api/eslint-rules/__tests__/fixtures/tsconfig.json b/packages/api/eslint-rules/__tests__/fixtures/tsconfig.json new file mode 100644 index 0000000..42091bb --- /dev/null +++ b/packages/api/eslint-rules/__tests__/fixtures/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext", "lib": ["ES2022"], "noEmit": true }, + "include": ["*.ts"] +} diff --git a/packages/api/eslint-rules/__tests__/no-promise-transaction-callback.test.ts b/packages/api/eslint-rules/__tests__/no-promise-transaction-callback.test.ts new file mode 100644 index 0000000..2a10889 --- /dev/null +++ b/packages/api/eslint-rules/__tests__/no-promise-transaction-callback.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'vitest' +import { join } from 'path' +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import { RuleTester } from 'eslint' +import parser from '@typescript-eslint/parser' +import rule from '../no-promise-transaction-callback.mjs' + +RuleTester.describe = describe +RuleTester.it = it + +const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') + +const ruleTester = new RuleTester({ + languageOptions: { + parser, + parserOptions: { project: './tsconfig.json', tsconfigRootDir: fixtures }, + }, +}) + +/** Drizzle's bun-sqlite shape: the callback runs synchronously and its result is returned. */ +const database = ` + type Tx = { insert(value: number): void } + declare const db: { transaction(fn: (tx: Tx) => T): T } + declare function helper(tx: Tx): Promise + declare function syncHelper(tx: Tx): number +` +const filename = join(fixtures, 'file.ts') +const invalid = (code: string) => ({ code: database + code, filename, errors: [{ messageId: 'promiseCallback' }] }) + +ruleTester.run('no-promise-transaction-callback', rule, { + valid: [ + { code: `${database} db.transaction((tx) => { tx.insert(1) })`, filename }, + { code: `${database} db.transaction(syncHelper)`, filename }, + { code: `${database} function doWrites(tx: Tx) { tx.insert(1); return 1 } db.transaction(doWrites)`, filename }, + { code: `${database} declare const other: { run(fn: () => Promise): void }; other.run(async () => {})`, filename }, + ], + invalid: [ + invalid(`db.transaction(async (tx) => { tx.insert(1) })`), + invalid(`db.transaction(async function (tx) { tx.insert(1) })`), + invalid(`async function doWrites(tx: Tx) { tx.insert(1) } db.transaction(doWrites)`), + invalid(`db.transaction((tx) => helper(tx))`), + invalid(`db.transaction(helper)`), + invalid(`declare const maybe: (tx: Tx) => Promise | void; db.transaction(maybe)`), + ], +}) diff --git a/packages/api/eslint-rules/no-promise-transaction-callback.mjs b/packages/api/eslint-rules/no-promise-transaction-callback.mjs new file mode 100644 index 0000000..bc8c09c --- /dev/null +++ b/packages/api/eslint-rules/no-promise-transaction-callback.mjs @@ -0,0 +1,67 @@ +/** + * Flags a callback passed to `.transaction(...)` whose return type is Promise-like. + * + * Drizzle's bun-sqlite `transaction` (and bun:sqlite's own) runs the callback synchronously and + * commits when it returns, so a callback that returns a Promise commits at its first `await`. + * Checking the callback's return type catches inline `async` functions, named async functions + * (`db.transaction(doWrites)`), and sync functions that return a Promise (`tx => helper(tx)`). + */ + +/** @param {import('typescript').Type} type */ +const constituents = (type) => (type.isUnion() ? type.types : [type]) + +/** + * @param {import('typescript').TypeChecker} checker + * @param {import('typescript').Type} type + * @param {import('typescript').Node} location + */ +function isThenable(checker, type, location) { + return constituents(type).some((part) => { + const then = part.getProperty('then') + return then != null && checker.getTypeOfSymbolAtLocation(then, location).getCallSignatures().length > 0 + }) +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + docs: { description: 'Disallow callbacks that return a Promise in database.transaction(...)' }, + messages: { + promiseCallback: + "Drizzle's bun-sqlite transaction commits when the callback returns, so a callback that returns a Promise commits at its first await. Use withWriteTransaction from src/db/write-transaction.ts, or a synchronous callback.", + }, + schema: [], + }, + create(context) { + const services = context.sourceCode.parserServices + if (!services?.program || !services.esTreeNodeToTSNodeMap) { + throw new Error('no-promise-transaction-callback needs type information (parserOptions.project).') + } + const checker = services.program.getTypeChecker() + + return { + CallExpression(node) { + const { callee } = node + if ( + callee.type !== 'MemberExpression' || + callee.computed || + callee.property.type !== 'Identifier' || + callee.property.name !== 'transaction' + ) { + return + } + const callback = node.arguments[0] + if (!callback || callback.type === 'SpreadElement') return + + const tsNode = services.esTreeNodeToTSNodeMap.get(callback) + const returnsPromise = constituents(checker.getTypeAtLocation(tsNode)).some((part) => + part + .getCallSignatures() + .some((signature) => isThenable(checker, checker.getReturnTypeOfSignature(signature), tsNode)), + ) + if (returnsPromise) context.report({ node: callback, messageId: 'promiseCallback' }) + }, + } + }, +} diff --git a/packages/api/eslint.config.mjs b/packages/api/eslint.config.mjs index 8b0d012..db97ad5 100644 --- a/packages/api/eslint.config.mjs +++ b/packages/api/eslint.config.mjs @@ -1,33 +1,40 @@ import tseslint from "@typescript-eslint/eslint-plugin"; import parser from "@typescript-eslint/parser"; +import noPromiseTransactionCallback from "./eslint-rules/no-promise-transaction-callback.mjs"; + +// tsconfig.json leaves out tests (they aren't built); tsconfig.eslint.json adds them so the +// transaction rule has type information there too. +const typedLanguageOptions = { + parser, + parserOptions: { + project: "./tsconfig.eslint.json", + tsconfigRootDir: import.meta.dirname, + }, +}; export default [ { - ignores: ["dist/**", "coverage/**", "node_modules/**", "src/**/__tests__/**"], + ignores: ["dist/**", "coverage/**", "node_modules/**"], }, { files: ["src/**/*.ts"], - languageOptions: { - parser, - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, + ignores: ["src/**/__tests__/**"], + languageOptions: typedLanguageOptions, plugins: { "@typescript-eslint": tseslint, }, rules: { "@typescript-eslint/no-unnecessary-condition": "error", - "no-restricted-syntax": [ - "error", - { - selector: - "CallExpression[callee.property.name='transaction'] > :matches(ArrowFunctionExpression, FunctionExpression)[async=true]", - message: - "Drizzle's bun-sqlite transaction commits at the callback's first await. Use withWriteTransaction from src/db/write-transaction.ts, or a synchronous callback.", - }, - ], + }, + }, + { + files: ["src/**/*.ts"], + languageOptions: typedLanguageOptions, + plugins: { + local: { rules: { "no-promise-transaction-callback": noPromiseTransactionCallback } }, + }, + rules: { + "local/no-promise-transaction-callback": "error", }, }, ]; diff --git a/packages/api/package.json b/packages/api/package.json index 8c7bae9..5ae1e02 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,10 +9,10 @@ "start": "bun dist/index.js", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .ts", - "test": "NODE_ENV=test bun test src", - "test:watch": "NODE_ENV=test bun test --watch src", + "test": "NODE_ENV=test bun test src eslint-rules", + "test:watch": "NODE_ENV=test bun test --watch src eslint-rules", "test:ui": "vitest --ui", - "test:coverage": "NODE_ENV=test bun test src --coverage", + "test:coverage": "NODE_ENV=test bun test src eslint-rules --coverage", "demo:seed": "bun src/lib/demo-seed.ts" }, "dependencies": { @@ -39,4 +39,4 @@ "typescript": "^5.9.3", "vitest": "^4.0.16" } -} \ No newline at end of file +} diff --git a/packages/api/src/db/README.md b/packages/api/src/db/README.md index 35c19d2..3a60d72 100644 --- a/packages/api/src/db/README.md +++ b/packages/api/src/db/README.md @@ -8,7 +8,7 @@ See [ADR-0003: SQLite schema evolution](../../../docs/adr/0003-sqlite-schema-evo Use `openOperationalDatabase(optionalPath?)` from the client module. It resolves the file path, enables WAL, turns on foreign keys, runs schema evolution, and returns Drizzle + raw `bun:sqlite` handles. Importing the client module does **not** open a connection. -Multi-statement writes that `await` must use `withWriteTransaction` from `write-transaction.ts`. Drizzle's bun-sqlite `db.transaction(async () => …)` commits when the callback first yields; it does not await the returned Promise. ESLint rejects an `async` callback passed to `.transaction(...)`. +Multi-statement writes that `await` must use `withWriteTransaction` from `write-transaction.ts`. Drizzle's bun-sqlite `db.transaction(async () => …)` commits when the callback first yields; it does not await the returned Promise. ESLint (`eslint-rules/no-promise-transaction-callback.mjs`, tests included) rejects any callback passed to `.transaction(...)` whose return type is Promise-like: an `async` function, a named async function, or a sync function that returns a Promise. Tests should use the same entry point (e.g. `openOperationalDatabase(':memory:')` via `setupTestDatabase()`), except a test that checks the snapshot itself: `__tests__/initial-schema-snapshot.test.ts` applies `initial_schema.sql` alone, because the entry point would run pending migrations on top and hide a stale snapshot. diff --git a/packages/api/tsconfig.eslint.json b/packages/api/tsconfig.eslint.json new file mode 100644 index 0000000..1333611 --- /dev/null +++ b/packages/api/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 6bc3c0042b04924c4f6aac4e413812a1fbf0265f Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:52:38 -0700 Subject: [PATCH 25/85] fix(web): say "control batches" in the wizard's fallback error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/wizards/ReviewStep.tsx | 2 +- .../wizards/__tests__/ReviewStep.test.tsx | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/wizards/ReviewStep.tsx b/packages/web/src/components/wizards/ReviewStep.tsx index 0420277..08ac3ad 100644 --- a/packages/web/src/components/wizards/ReviewStep.tsx +++ b/packages/web/src/components/wizards/ReviewStep.tsx @@ -221,7 +221,7 @@ export default function ReviewStep({ else setError('No control batches were created') } catch (err: unknown) { console.error('Multi-batch submit failed:', err) - setError(getQueryErrorMessage(err, 'Failed to create batches')) + setError(getQueryErrorMessage(err, 'Failed to create control batches')) } finally { setSubmitting(false) } diff --git a/packages/web/src/components/wizards/__tests__/ReviewStep.test.tsx b/packages/web/src/components/wizards/__tests__/ReviewStep.test.tsx index 2c14121..0d43e84 100644 --- a/packages/web/src/components/wizards/__tests__/ReviewStep.test.tsx +++ b/packages/web/src/components/wizards/__tests__/ReviewStep.test.tsx @@ -259,6 +259,32 @@ describe('ReviewStep multi-batch CSV errors', () => { expect(await screen.findByText(/No control batches were created/)).toBeInTheDocument() }) + + it('says "control batches" when the failure carries no message', async () => { + vi.clearAllMocks() + mockSuggestBatchName.mockResolvedValue({ name: 'Suggested Batch' }) + mockCreateBatchesWithSpecimens.mockRejectedValue({}) + const def5000: ControlDefinition = { + id: 10, name: 'Def 5000', controlType: 'blood', created: '', lastUpdated: '', targetDensity: 5000, unitSymbol: 'µL', + } + render( + {}} + onCancel={() => {}} + onSuccess={() => {}} + isAddMode={false} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: /Create Batch/i })) + + expect(await screen.findByText('Failed to create control batches')).toBeInTheDocument() + }) }) describe('ReviewStep multi-batch CSV nested collection placement', () => { From 31be02a6bea7c0f785d6537b0236a367d055be97 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:53:15 -0700 Subject: [PATCH 26/85] refactor(api): move chunkArray and dateToUpperBound to lib/sql-helpers. specimen-types.ts drops its own copy of chunkArray. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/export/query.ts | 2 +- .../api/src/lib/specimens/specimen-read.ts | 2 +- packages/api/src/lib/sql-helpers.ts | 21 ++++++++++++++++++ .../lib/statistics/container-aggregates.ts | 2 +- .../api/src/lib/statistics/dashboard-stats.ts | 3 ++- packages/api/src/lib/statistics/helpers.ts | 22 ++----------------- .../src/lib/statistics/specimen-aggregates.ts | 2 +- .../src/lib/statistics/storage-aggregates.ts | 2 +- packages/api/src/routes/specimen-types.ts | 12 +--------- 9 files changed, 31 insertions(+), 37 deletions(-) create mode 100644 packages/api/src/lib/sql-helpers.ts diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index c7c36d9..8be1f82 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -21,7 +21,7 @@ import type { StudyRecord, } from './types' import { buildExportSummary, validateStudyCodes } from './validate' -import { chunkArray, dateToUpperBound } from '../statistics/helpers' +import { chunkArray, dateToUpperBound } from '../sql-helpers' import { shiftCalendarDate } from '../datetime' export async function buildContainerQuery(database: Database, filters: ExportFilters) { diff --git a/packages/api/src/lib/specimens/specimen-read.ts b/packages/api/src/lib/specimens/specimen-read.ts index 9ca373a..327b24a 100644 --- a/packages/api/src/lib/specimens/specimen-read.ts +++ b/packages/api/src/lib/specimens/specimen-read.ts @@ -3,7 +3,7 @@ import { specimen, storageContainer, studySubject, study, specimenType, controlB import { eq, and, or, sql } from 'drizzle-orm' import { validatePage, validateLimit } from '../constants' import { resolveContainerByBarcode } from '../identifier-resolution' -import { dateToUpperBound } from '../statistics/helpers' +import { dateToUpperBound } from '../sql-helpers' import { likeContains } from '../sql-like' export type ListSpecimensQuery = { diff --git a/packages/api/src/lib/sql-helpers.ts b/packages/api/src/lib/sql-helpers.ts new file mode 100644 index 0000000..b9c663f --- /dev/null +++ b/packages/api/src/lib/sql-helpers.ts @@ -0,0 +1,21 @@ +import { lt, lte } from 'drizzle-orm' +import type { SQLiteColumn } from 'drizzle-orm/sqlite-core' +import { shiftCalendarDate } from './datetime' + +/** + * Upper bound for a "to" date filter. A bare YYYY-MM-DD includes that whole day, so a + * timestamp column ('2026-05-26 22:28:26') is compared with "< next day", not "<= date". + */ +export function dateToUpperBound(column: SQLiteColumn, dateTo: string) { + const next = /^\d{4}-\d{2}-\d{2}$/.test(dateTo) ? shiftCalendarDate(dateTo, 1) : null + return next ? lt(column, next) : lte(column, dateTo) +} + +/** Batch an array into chunks for SQLite variable limits. */ +export function chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = [] + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)) + } + return chunks +} diff --git a/packages/api/src/lib/statistics/container-aggregates.ts b/packages/api/src/lib/statistics/container-aggregates.ts index cc90d00..9639317 100644 --- a/packages/api/src/lib/statistics/container-aggregates.ts +++ b/packages/api/src/lib/statistics/container-aggregates.ts @@ -2,7 +2,7 @@ import type { Database } from '../../db/client' import { storageContainer, storageContainerTag, tag } from '../../db/schema' import { eq, inArray } from 'drizzle-orm' import type { ContainerSubtype } from '../container-placement' -import { chunkArray } from './helpers' +import { chunkArray } from '../sql-helpers' export type ContainerAggregates = { byType: Record diff --git a/packages/api/src/lib/statistics/dashboard-stats.ts b/packages/api/src/lib/statistics/dashboard-stats.ts index 9f08713..f9676a4 100644 --- a/packages/api/src/lib/statistics/dashboard-stats.ts +++ b/packages/api/src/lib/statistics/dashboard-stats.ts @@ -11,7 +11,8 @@ import { } from '../../db/schema' import { eq, and, sql, inArray } from 'drizzle-orm' import { resolveContainerTypes } from '../container-placement' -import { buildDateFilter, chunkArray } from './helpers' +import { buildDateFilter } from './helpers' +import { chunkArray } from '../sql-helpers' import { computeContainerAggregates } from './container-aggregates' import { resolveContainerIdsAtLocations, diff --git a/packages/api/src/lib/statistics/helpers.ts b/packages/api/src/lib/statistics/helpers.ts index 464f4a8..1b7e3a1 100644 --- a/packages/api/src/lib/statistics/helpers.ts +++ b/packages/api/src/lib/statistics/helpers.ts @@ -1,6 +1,6 @@ -import { gte, lt, lte } from 'drizzle-orm' +import { gte } from 'drizzle-orm' import type { SQLiteColumn } from 'drizzle-orm/sqlite-core' -import { shiftCalendarDate } from '../datetime' +import { dateToUpperBound } from '../sql-helpers' /** Build date filter conditions for specimen date columns. */ export function buildDateFilter(column: SQLiteColumn, dateFrom?: string, dateTo?: string) { @@ -13,21 +13,3 @@ export function buildDateFilter(column: SQLiteColumn, dateFrom?: string, dateTo? } return conditions } - -/** - * Upper bound for a "to" date filter. A bare YYYY-MM-DD includes that whole day, so a - * timestamp column ('2026-05-26 22:28:26') is compared with "< next day", not "<= date". - */ -export function dateToUpperBound(column: SQLiteColumn, dateTo: string) { - const next = /^\d{4}-\d{2}-\d{2}$/.test(dateTo) ? shiftCalendarDate(dateTo, 1) : null - return next ? lt(column, next) : lte(column, dateTo) -} - -/** Batch an array into chunks for SQLite variable limits. */ -export function chunkArray(array: T[], chunkSize: number): T[][] { - const chunks: T[][] = [] - for (let i = 0; i < array.length; i += chunkSize) { - chunks.push(array.slice(i, i + chunkSize)) - } - return chunks -} diff --git a/packages/api/src/lib/statistics/specimen-aggregates.ts b/packages/api/src/lib/statistics/specimen-aggregates.ts index e29229c..e03dd5c 100644 --- a/packages/api/src/lib/statistics/specimen-aggregates.ts +++ b/packages/api/src/lib/statistics/specimen-aggregates.ts @@ -2,7 +2,7 @@ import type { Database } from '../../db/client' import { specimen, specimenType, study, studySubject } from '../../db/schema' import { inArray } from 'drizzle-orm' import { cache, cacheKeys } from '../cache' -import { chunkArray } from './helpers' +import { chunkArray } from '../sql-helpers' export type SpecimenAggregates = { total: number diff --git a/packages/api/src/lib/statistics/storage-aggregates.ts b/packages/api/src/lib/statistics/storage-aggregates.ts index 0d4c660..8924604 100644 --- a/packages/api/src/lib/statistics/storage-aggregates.ts +++ b/packages/api/src/lib/statistics/storage-aggregates.ts @@ -2,7 +2,7 @@ import type { Database } from '../../db/client' import { location } from '../../db/schema' import { inArray } from 'drizzle-orm' import { resolveContainerPlacements } from '../container-placement' -import { chunkArray } from './helpers' +import { chunkArray } from '../sql-helpers' export type StorageStatistics = { byLocation: Array<{ location: string; count: number }> diff --git a/packages/api/src/routes/specimen-types.ts b/packages/api/src/routes/specimen-types.ts index b3e527d..ca60b73 100644 --- a/packages/api/src/routes/specimen-types.ts +++ b/packages/api/src/routes/specimen-types.ts @@ -17,6 +17,7 @@ import { parseId, requireParam } from '../lib/common-validators' import { handleRouteError, NotFoundError } from '../lib/error-handler' import { createAdminMiddleware, createAuthMiddleware } from '../middleware/auth' import { utcNow } from '../lib/datetime' +import { chunkArray } from '../lib/sql-helpers' const createSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -53,17 +54,6 @@ async function checkSpecimenTypeInUse(id: number, database: any): Promise(array: T[], chunkSize: number): T[][] { - const chunks: T[][] = [] - for (let i = 0; i < array.length; i += chunkSize) { - chunks.push(array.slice(i, i + chunkSize)) - } - return chunks -} - /** * Check if a container type is in use by existing containers for a specimen type */ From e87c4ae92f1d4d679b64da8912481be3f31c75d3 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:53:44 -0700 Subject: [PATCH 27/85] refactor(controls): share the control definition name check. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/src/lib/control-name-generation.ts | 9 +++++++ packages/api/src/routes/controls.ts | 27 ++++++------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/api/src/lib/control-name-generation.ts b/packages/api/src/lib/control-name-generation.ts index 68432f6..2db2168 100644 --- a/packages/api/src/lib/control-name-generation.ts +++ b/packages/api/src/lib/control-name-generation.ts @@ -187,3 +187,12 @@ export async function generateUniqueControlDefinitionName( return `${baseName}_${maxIncrement + 1}` } +/** Whether a control definition already uses this exact name. */ +export async function isControlDefinitionNameTaken(database: Database, name: string): Promise { + const existing = await database + .select({ id: controlDefinition.id }) + .from(controlDefinition) + .where(eq(controlDefinition.name, name)) + .get() + return existing != null +} diff --git a/packages/api/src/routes/controls.ts b/packages/api/src/routes/controls.ts index 7328f40..a15eff5 100644 --- a/packages/api/src/routes/controls.ts +++ b/packages/api/src/routes/controls.ts @@ -9,7 +9,11 @@ import { eq, and, sql } from 'drizzle-orm' import { z } from 'zod' import { parseControlProperties } from '../lib/control-properties' import { validateControlBatchName, generateUniqueBatchName } from '../lib/validation' -import { generateControlDefinitionName, generateUniqueControlDefinitionName } from '../lib/control-name-generation' +import { + generateControlDefinitionName, + generateUniqueControlDefinitionName, + isControlDefinitionNameTaken, +} from '../lib/control-name-generation' import { handleRouteError } from '../lib/error-handler' import type { BloodControlProperties } from '../types/properties' import { createAuthMiddleware, createMemberMiddleware } from '../middleware/auth' @@ -396,12 +400,7 @@ controls.post('/definitions/bulk', memberMiddleware, async (c) => { let finalName: string const customName = providedNames[i]?.trim() if (customName) { - const existingByName = await dbInstance - .select({ id: controlDefinition.id }) - .from(controlDefinition) - .where(eq(controlDefinition.name, customName)) - .get() - if (existingByName) { + if (await isControlDefinitionNameTaken(dbInstance, customName)) { return c.json({ error: `Control definition name "${customName}" is already in use` }, 400) } finalName = customName @@ -497,12 +496,7 @@ controls.post('/', memberMiddleware, async (c) => { strains: strainsWithNames, }) } else { - const existingByName = await dbInstance - .select() - .from(controlDefinition) - .where(eq(controlDefinition.name, finalName)) - .get() - if (existingByName) { + if (await isControlDefinitionNameTaken(dbInstance, finalName)) { return c.json({ error: 'A control definition with this name already exists' }, 409) } } @@ -588,12 +582,7 @@ controls.patch('/:id', memberMiddleware, async (c) => { } if (baseData.name !== undefined && baseData.name !== existing.name) { - const existingByName = await dbInstance - .select({ id: controlDefinition.id }) - .from(controlDefinition) - .where(eq(controlDefinition.name, baseData.name)) - .get() - if (existingByName) { + if (await isControlDefinitionNameTaken(dbInstance, baseData.name)) { return c.json({ error: 'A control definition with this name already exists' }, 409) } } From 4ebfa8c60c3dc40ef48f6ba38e9c11694820f022 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:54:04 -0700 Subject: [PATCH 28/85] fix(db): give each request its own transaction on the shared connection. withRolledBackTransaction left BEGIN open across awaits on the one bun:sqlite connection. A request that wrote while a validate was open got "within a transaction" from its own BEGIN, ran inside the validate's transaction, and lost its writes when the validate rolled back, after it had already reported success. Two overlapping withWriteTransaction calls shared one transaction the same way. The open transaction now lives in an AsyncLocalStorage, and a lock per connection serializes transactions. A call from inside the same async context runs in a savepoint; a call from any other context waits for the lock and opens its own transaction. Nesting no longer depends on the SQLite error text. A nested withWriteTransaction that throws now rolls back its own writes, so a per_subject import run inside an open transaction drops the failed subject's Subject row too. ADR 0010 says how per_subject gets that. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0010-specimen-registration.md | 2 +- packages/api/src/bun-sqlite.d.ts | 2 +- .../db/__tests__/write-transaction.test.ts | 50 +++++- packages/api/src/db/write-transaction.ts | 170 ++++++++++++------ .../__tests__/bulk-combined-import.test.ts | 25 +++ 5 files changed, 192 insertions(+), 57 deletions(-) diff --git a/docs/adr/0010-specimen-registration.md b/docs/adr/0010-specimen-registration.md index f296901..db29e53 100644 --- a/docs/adr/0010-specimen-registration.md +++ b/docs/adr/0010-specimen-registration.md @@ -21,7 +21,7 @@ We put **Specimen registration** (see [CONTEXT.md](../../CONTEXT.md)) behind one - **Collection identity**: every identifier given (id, barcode, name) must resolve to the same **Collection**. One that misses while another matches, or two that match different collections, is an error. A Collection is created only when nothing matches and a location is given. Sheets are created under their box or bag when missing. - **Placement**: one **Grid position** per container per collection, checked against the database and the rest of the request by resolved collection, after normalizing positions (`A2` → `A02`). Any number of Papers share a Sheet; a duplicate sublabel on one Sheet is an error. - **Units**: an omitted unit takes the configured default for the container type; an unknown or disallowed unit is an error. No hardcoded container-type default. -- **Atomicity**: one call writes everything or nothing. Atomicity across calls is the caller's choice: bulk combined `per_subject` mode calls once per subject in its own savepoint. +- **Atomicity**: one call writes everything or nothing. Atomicity across calls is the caller's choice: bulk combined `per_subject` mode registers each subject in its own `withWriteTransaction`, which becomes a savepoint when an outer transaction is open, so a failed subject leaves nothing behind, its Subject row included. - **Errors**: problems found while planning are a `SpecimenRegistrationError` with status 400 and the full error list. A constraint violation while writing is the same error with status 409. ## Considered options diff --git a/packages/api/src/bun-sqlite.d.ts b/packages/api/src/bun-sqlite.d.ts index 35eee7f..be6220f 100644 --- a/packages/api/src/bun-sqlite.d.ts +++ b/packages/api/src/bun-sqlite.d.ts @@ -9,7 +9,7 @@ declare module 'bun:sqlite' { run(sql: string, ...params: any[]): void serialize(): Uint8Array loadExtension(path: string): void - inTransaction(): boolean + readonly inTransaction: boolean transaction(fn: () => T): T } diff --git a/packages/api/src/db/__tests__/write-transaction.test.ts b/packages/api/src/db/__tests__/write-transaction.test.ts index 6c41ff5..2cc46ba 100644 --- a/packages/api/src/db/__tests__/write-transaction.test.ts +++ b/packages/api/src/db/__tests__/write-transaction.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { setupTestDatabase, cleanupTestDatabase } from '../../__tests__/helpers/db-setup' import { createTestStudy } from '../../__tests__/helpers/factories' import { study } from '../schema' -import { withWriteTransaction } from '../write-transaction' +import { withRolledBackTransaction, withWriteTransaction } from '../write-transaction' import type { Database } from '../client' describe('withWriteTransaction', () => { @@ -53,4 +53,52 @@ describe('withWriteTransaction', () => { const rows = await testDb.select().from(study) expect(rows.find((row) => row.shortCode === 'N1')).toBeUndefined() }) + + it('keeps a write from another request that lands while a validate is open', async () => { + let validateOpen!: () => void + const opened = new Promise((resolve) => (validateOpen = resolve)) + let finishValidate!: () => void + const finish = new Promise((resolve) => (finishValidate = resolve)) + + const validate = withRolledBackTransaction(testDb, async (db) => { + await createTestStudy(db, { title: 'Checked Study', shortCode: 'CHK' }) + validateOpen() + await finish + }) + await opened + // Another request, from its own async context, writes while the validate is still open. + const write = withWriteTransaction(testDb, async (db) => { + await createTestStudy(db, { title: 'Saved Study', shortCode: 'SAVED' }) + }) + finishValidate() + await Promise.all([validate, write]) + + const codes = (await testDb.select().from(study)).map((row) => row.shortCode) + expect(codes).toContain('SAVED') + expect(codes).not.toContain('CHK') + }) + + it('runs overlapping writers in separate transactions', async () => { + let firstOpen!: () => void + const opened = new Promise((resolve) => (firstOpen = resolve)) + let failFirst!: () => void + const fail = new Promise((resolve) => (failFirst = resolve)) + + const first = withWriteTransaction(testDb, async (db) => { + await createTestStudy(db, { title: 'Failed Study', shortCode: 'FAIL' }) + firstOpen() + await fail + throw new Error('first fails') + }) + await opened + const second = withWriteTransaction(testDb, async (db) => { + await createTestStudy(db, { title: 'Second Study', shortCode: 'SECOND' }) + }) + failFirst() + await expect(first).rejects.toThrow('first fails') + await second + + const codes = (await testDb.select().from(study)).map((row) => row.shortCode) + expect(codes).toEqual(['SECOND']) + }) }) diff --git a/packages/api/src/db/write-transaction.ts b/packages/api/src/db/write-transaction.ts index a08d70e..2906dd6 100644 --- a/packages/api/src/db/write-transaction.ts +++ b/packages/api/src/db/write-transaction.ts @@ -1,3 +1,13 @@ +/** + * Transactions that stay open across `await` on the one shared bun:sqlite connection. + * + * A transaction belongs to the async context that opened it. A helper called from inside that + * context (truly nested) runs in a savepoint of the open transaction. A helper called from any + * other context, such as another request, waits for the connection's lock and then opens its + * own transaction, so two requests never share one. See the db README for what the lock does + * not cover. + */ +import { AsyncLocalStorage } from 'node:async_hooks' import type { Database as SQLiteDatabase } from 'bun:sqlite' import type { Database } from './client' @@ -15,51 +25,126 @@ function sqliteClientOf(database: object): SQLiteDatabase | undefined { return undefined } -function isAlreadyInTransactionMessage(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('within a transaction') +/** Serializes transactions on one connection, first come first served. */ +class ConnectionLock { + private tail: Promise = Promise.resolve() + + async acquire(): Promise<() => void> { + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + const previous = this.tail + this.tail = previous.then(() => released) + await previous + return release + } } -/** - * Run writes in one SQLite transaction that stays open across `await`. - * - * Drizzle's bun-sqlite `db.transaction(async () => ...)` commits when the - * callback first yields (it does not await the returned Promise). Callers - * already inside a transaction run `fn` as-is. - */ -export async function withWriteTransaction( - database: Database, - fn: (database: Database) => Promise, -): Promise { - const sqlite = sqliteClientOf(database) - if (!sqlite) { - return fn(database) +const locks = new WeakMap() + +function lockFor(sqlite: SQLiteDatabase): ConnectionLock { + let lock = locks.get(sqlite) + if (!lock) { + lock = new ConnectionLock() + locks.set(sqlite, lock) } + return lock +} +/** The transaction the current async context opened, while it is still open. */ +type OpenTransaction = { sqlite: SQLiteDatabase; open: boolean } + +const currentTransaction = new AsyncLocalStorage() + +function joinedTransaction(sqlite: SQLiteDatabase): OpenTransaction | undefined { + const current = currentTransaction.getStore() + return current && current.open && current.sqlite === sqlite ? current : undefined +} + +/** Read fresh each time: statements run inside `fn` can end the transaction. */ +function inTransaction(sqlite: SQLiteDatabase): boolean { + return sqlite.inTransaction +} + +let savepointCounter = 0 + +async function inSavepoint( + sqlite: SQLiteDatabase, + keep: boolean, + fn: () => Promise, +): Promise { + const name = `sp_${++savepointCounter}` + sqlite.exec(`SAVEPOINT ${name}`) + let result: T try { - sqlite.exec('BEGIN') + result = await fn() } catch (error) { - if (isAlreadyInTransactionMessage(error)) { - return fn(database) - } + sqlite.exec(`ROLLBACK TO ${name}`) + sqlite.exec(`RELEASE ${name}`) throw error } + if (!keep) sqlite.exec(`ROLLBACK TO ${name}`) + sqlite.exec(`RELEASE ${name}`) + return result +} + +/** + * Run `fn` in a transaction of its own, then commit (`keep`) or roll back. Nested calls from + * the same async context use a savepoint; calls from other contexts wait their turn. + */ +async function runTransaction( + sqlite: SQLiteDatabase, + keep: boolean, + fn: () => Promise, +): Promise { + if (joinedTransaction(sqlite)) return inSavepoint(sqlite, keep, fn) + const release = await lockFor(sqlite).acquire() try { - const result = await fn(database) - sqlite.exec('COMMIT') - return result - } catch (error) { + if (inTransaction(sqlite)) { + // Opened outside these helpers (raw BEGIN): it is not ours to commit, so nest in it. + const transaction: OpenTransaction = { sqlite, open: true } + try { + return await currentTransaction.run(transaction, () => inSavepoint(sqlite, keep, fn)) + } finally { + transaction.open = false + } + } + + const transaction: OpenTransaction = { sqlite, open: true } + sqlite.exec('BEGIN') + let result: T try { - sqlite.exec('ROLLBACK') - } catch { - // Connection may already have left the transaction. + result = await currentTransaction.run(transaction, fn) + } catch (error) { + transaction.open = false + if (inTransaction(sqlite)) sqlite.exec('ROLLBACK') + throw error } - throw error + transaction.open = false + sqlite.exec(keep ? 'COMMIT' : 'ROLLBACK') + return result + } finally { + release() } } -let savepointCounter = 0 +/** + * Run writes in one SQLite transaction that stays open across `await`, and commit them. + * + * Drizzle's bun-sqlite `db.transaction(async () => ...)` commits when the callback first + * yields (it does not await the returned Promise). Called inside another helper's `fn`, this + * runs in a savepoint: if `fn` throws, only its own writes roll back. + */ +export async function withWriteTransaction( + database: Database, + fn: (database: Database) => Promise, +): Promise { + const sqlite = sqliteClientOf(database) + if (!sqlite) { + return fn(database) + } + return runTransaction(sqlite, true, () => fn(database)) +} /** * Run writes and always roll them back, returning what `fn` returned. Use it to check @@ -74,28 +159,5 @@ export async function withRolledBackTransaction( if (!sqlite) { throw new Error('withRolledBackTransaction needs a bun:sqlite database') } - - try { - sqlite.exec('BEGIN') - } catch (error) { - if (!isAlreadyInTransactionMessage(error)) throw error - const name = `rolled_back_${++savepointCounter}` - sqlite.exec(`SAVEPOINT ${name}`) - try { - return await fn(database) - } finally { - sqlite.exec(`ROLLBACK TO ${name}`) - sqlite.exec(`RELEASE ${name}`) - } - } - - try { - return await fn(database) - } finally { - try { - sqlite.exec('ROLLBACK') - } catch { - // Connection may already have left the transaction. - } - } + return runTransaction(sqlite, false, () => fn(database)) } diff --git a/packages/api/src/lib/__tests__/bulk-combined-import.test.ts b/packages/api/src/lib/__tests__/bulk-combined-import.test.ts index 8c91ae5..2323af6 100644 --- a/packages/api/src/lib/__tests__/bulk-combined-import.test.ts +++ b/packages/api/src/lib/__tests__/bulk-combined-import.test.ts @@ -20,6 +20,7 @@ import { specimenTypeContainerType, containerTypeUnit, studySubject, specimen } import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' import { utcNow } from '../datetime' +import { withWriteTransaction } from '../../db/write-transaction' describe('bulk-combined-import', () => { let testDb: Database @@ -148,6 +149,30 @@ describe('bulk-combined-import', () => { expect(result.summary.specimensCreated).toBe(2) }) + it('per_subject mode inside an open transaction rolls back only the failed subject', async () => { + const study = await createTestStudy(testDb, { title: 'Study 1', shortCode: 'ST1' }) + const specimenType = await createTestSpecimenType(testDb, { name: 'DNA' }) + + const result = await withWriteTransaction(testDb, (tx) => + runBulkCombinedImport( + tx, + { + studyShortCode: study.shortCode, + atomicMode: 'per_subject', + subjects: [ + { subjectName: 'GoodSubj', specimens: [{ specimenTypeName: specimenType.name, collectionDate: '2024-01-15' }] }, + { subjectName: 'BadSubj', specimens: [{ specimenTypeName: 'NonExistentType', collectionDate: '2024-01-15' }] }, + ], + }, + undefined + ) + ) + + expect(result.errors).toHaveLength(1) + const subjects = await testDb.select().from(studySubject).all() + expect(subjects.map((s) => s.name)).toEqual(['GoodSubj']) + }) + for (const atomicMode of ['full_file', 'per_subject'] as const) { it(`places each subject in its own study for a mixed-study plate (${atomicMode})`, async () => { const studyA = await createTestStudy(testDb, { title: 'Study A', shortCode: 'STA' }) From 1b730365d0c1d20b512d6efe1eb0e3eb40d132e6 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:54:40 -0700 Subject: [PATCH 29/85] docs(db): document withRolledBackTransaction and the transaction lock. The db README covered only withWriteTransaction. It now lists both helpers, how the per-connection lock and AsyncLocalStorage decide between waiting, a savepoint, and a new BEGIN, and the writes the lock does not cover: plain writes and Drizzle's sync transactions still land in whatever transaction is open, and are lost if it rolls back. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/db/README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/api/src/db/README.md b/packages/api/src/db/README.md index bbefac7..4db7494 100644 --- a/packages/api/src/db/README.md +++ b/packages/api/src/db/README.md @@ -8,10 +8,36 @@ See [ADR-0003: SQLite schema evolution](../../../docs/adr/0003-sqlite-schema-evo Use `openOperationalDatabase(optionalPath?)` from the client module. It resolves the file path, enables WAL, turns on foreign keys, runs schema evolution, and returns Drizzle + raw `bun:sqlite` handles. Importing the client module does **not** open a connection. -Multi-statement writes that `await` must use `withWriteTransaction` from `write-transaction.ts`. Drizzle's bun-sqlite `db.transaction(async () => …)` commits when the callback first yields; it does not await the returned Promise. ESLint rejects an `async` callback passed to `.transaction(...)`. - Tests should use the same entry point (e.g. `openOperationalDatabase(':memory:')` via `setupTestDatabase()`). +## Transactions + +The API shares one `bun:sqlite` connection across all requests, so a transaction that stays open across `await` is visible to every request that runs while it is open. Use the helpers in `write-transaction.ts`: + +| Helper | Use it to | Ends with | +|--------|-----------|-----------| +| `withWriteTransaction(db, fn)` | Run multi-statement writes that `await` | `COMMIT`, or `ROLLBACK` if `fn` throws | +| `withRolledBackTransaction(db, fn)` | Check that a write would succeed by performing it (validate routes) | `ROLLBACK`, always | + +Drizzle's bun-sqlite `db.transaction(async () => …)` commits when the callback first yields; it does not await the returned Promise. ESLint rejects an `async` callback passed to `.transaction(...)`. + +### Concurrency model + +- **One transaction at a time per connection.** A process-wide lock per connection serializes the helpers. A call from another async context, such as another request, waits for the lock and then runs its own `BEGIN`. +- **Nesting follows the async context.** The open transaction is kept in an `AsyncLocalStorage`. A helper called from inside another helper's `fn` runs in a `SAVEPOINT` of the open transaction instead of waiting. If the nested `fn` throws, only its own writes roll back. A nested `withRolledBackTransaction` always rolls back to its savepoint and leaves the outer transaction's earlier writes in place. +- **A transaction opened outside the helpers** (a raw `sqlite.exec('BEGIN')`, as some tests do) is not the helpers' to commit: a helper called while one is open runs in a savepoint inside it. + +### What the lock does not cover + +The lock only orders the helpers. A write made outside them runs immediately and synchronously, and if another context has a helper's transaction open at that moment the write lands in that transaction: + +- If that transaction commits, the write commits with it. +- If it rolls back (a failed import, or any `withRolledBackTransaction` validate), the write is lost, though the request that made it has already reported success. + +Writes outside the helpers include plain `db.insert/update/delete` calls (about 220 in 38 files under `routes/`, `lib/`, and `middleware/`, among them session writes on login, rate-limit counters, and the error log that `handleRouteError` writes) and Drizzle's synchronous `db.transaction((tx) => …)`, which bun:sqlite turns into a savepoint inside the open transaction. Routing them through the lock would mean making every write in the API wait on an async lock; it has not been done. The exposure lasts as long as a helper's transaction stays open across `await`. + +When you add a multi-statement write, use `withWriteTransaction` instead of adding to that list. + ## Schema version - Table: `schema_version` with a single row (`version INTEGER NOT NULL`). From c524782c607beeb6d1e54e041ca3fe5fabbbcdd4 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:55:11 -0700 Subject: [PATCH 30/85] refactor(api): take an insert's returned row through one insertedRow helper. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/__tests__/inserted-row.test.ts | 12 ++++++++ packages/api/src/lib/bulk-combined-import.ts | 13 ++++----- .../src/lib/collections/collection-create.ts | 13 ++++----- .../lib/collections/collection-lifecycle.ts | 13 ++++----- packages/api/src/lib/container-creation.ts | 6 ++-- .../api/src/lib/container-write-placement.ts | 4 +-- packages/api/src/lib/controls/batch-create.ts | 6 ++-- .../src/lib/controls/batch-with-specimens.ts | 16 +++------- packages/api/src/lib/crud-routes.ts | 7 ++--- packages/api/src/lib/demo-seed.ts | 29 +++++-------------- packages/api/src/lib/derivations.ts | 4 +-- packages/api/src/lib/inserted-row.ts | 9 ++++++ .../api/src/lib/registration-orchestrator.ts | 7 ++--- packages/api/src/routes/controls.ts | 6 ++-- packages/api/src/routes/reagents.ts | 25 ++++++++-------- packages/api/src/routes/specimens.ts | 17 +++++------ packages/api/src/routes/studies.ts | 29 +++++++++---------- 17 files changed, 97 insertions(+), 119 deletions(-) create mode 100644 packages/api/src/lib/__tests__/inserted-row.test.ts create mode 100644 packages/api/src/lib/inserted-row.ts diff --git a/packages/api/src/lib/__tests__/inserted-row.test.ts b/packages/api/src/lib/__tests__/inserted-row.test.ts new file mode 100644 index 0000000..a195217 --- /dev/null +++ b/packages/api/src/lib/__tests__/inserted-row.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' +import { insertedRow } from '../inserted-row' + +describe('insertedRow', () => { + it('returns the first returned row', () => { + expect(insertedRow([{ id: 1 }, { id: 2 }], 'plate')).toEqual({ id: 1 }) + }) + + it('throws, naming the table, when the insert returned nothing', () => { + expect(() => insertedRow([], 'plate')).toThrow('Insert did not return plate row') + }) +}) diff --git a/packages/api/src/lib/bulk-combined-import.ts b/packages/api/src/lib/bulk-combined-import.ts index 6b914ca..83ad00f 100644 --- a/packages/api/src/lib/bulk-combined-import.ts +++ b/packages/api/src/lib/bulk-combined-import.ts @@ -33,6 +33,7 @@ import { type BulkCombinedContainerInput, } from './container-write-placement' import { withWriteTransaction } from '../db/write-transaction' +import { insertedRow } from './inserted-row' export type { BulkCombinedContainerInput } @@ -245,12 +246,8 @@ export async function createSubjectWithSpecimensInTx( updatedBy: userId, }) .returning() - .get() - const insertedSubject = Array.isArray(newSubjectResult) ? newSubjectResult[0] : newSubjectResult - if (!insertedSubject) { - throw new Error('Insert did not return study subject row') - } - subject = insertedSubject as typeof studySubject.$inferSelect + .all() + subject = insertedRow(newSubjectResult, 'study subject') subjectId = subject.id } @@ -277,8 +274,8 @@ export async function createSubjectWithSpecimensInTx( updatedBy: userId, }) .returning() - .get() - specimenRecord = (Array.isArray(newSpecimenResult) ? newSpecimenResult[0] : newSpecimenResult) as typeof specimen.$inferSelect + .all() + specimenRecord = insertedRow(newSpecimenResult, 'specimen') specimenCreated = true } const specimenId = specimenRecord.id diff --git a/packages/api/src/lib/collections/collection-create.ts b/packages/api/src/lib/collections/collection-create.ts index cf80fe6..b49c348 100644 --- a/packages/api/src/lib/collections/collection-create.ts +++ b/packages/api/src/lib/collections/collection-create.ts @@ -14,6 +14,7 @@ export { CollectionLocationNotFoundError, CollectionLocationNotAllowedError, } from './collection-lifecycle' +import { insertedRow } from '../inserted-row' export class CollectionNameExistsError extends Error { constructor(message: string) { @@ -40,8 +41,7 @@ export async function createMicronixPlate(database: Database, input: CreateColle updatedBy: input.userId, }) .returning() - const newPlate = inserted.at(0) - if (!newPlate) throw new Error('Insert did not return plate row') + const newPlate = insertedRow(inserted, 'plate') return { plate: { ...newPlate, locationPath: formatLocationPath(loc) } } } @@ -63,8 +63,7 @@ export async function createCryovialBox(database: Database, input: CreateCollect updatedBy: input.userId, }) .returning() - const newBox = inserted.at(0) - if (!newBox) throw new Error('Insert did not return cryovial box row') + const newBox = insertedRow(inserted, 'cryovial box') return { box: { ...newBox, locationPath: formatLocationPath(loc) } } } @@ -85,8 +84,7 @@ export async function createGenericBox(database: Database, input: CreateCollecti updatedBy: input.userId, }) .returning() - const newBox = inserted.at(0) - if (!newBox) throw new Error('Insert did not return box row') + const newBox = insertedRow(inserted, 'box') return { box: { ...newBox, locationPath: formatLocationPath(loc) } } } @@ -107,7 +105,6 @@ export async function createBag(database: Database, input: CreateCollectionInput updatedBy: input.userId, }) .returning() - const newBag = inserted.at(0) - if (!newBag) throw new Error('Insert did not return bag row') + const newBag = insertedRow(inserted, 'bag') return { bag: { ...newBag, locationPath: formatLocationPath(loc) } } } diff --git a/packages/api/src/lib/collections/collection-lifecycle.ts b/packages/api/src/lib/collections/collection-lifecycle.ts index 7f686d0..cb266a5 100644 --- a/packages/api/src/lib/collections/collection-lifecycle.ts +++ b/packages/api/src/lib/collections/collection-lifecycle.ts @@ -6,6 +6,7 @@ import type * as schema from '../../db/schema' import { location, micronixPlate, cryovialBox, box, bag } from '../../db/schema' import { resolveCollection } from './collection-resolve' import { utcNow } from '../datetime' +import { insertedRow } from '../inserted-row' type DatabaseOrTransaction = | Database @@ -94,8 +95,7 @@ export async function createOrResolveCollection( updatedBy: input.userId, }) .returning() - const row = inserted.at(0) - if (!row) throw new Error('Insert did not return box row') + const row = insertedRow(inserted, 'box') collectionMap?.set(key, row.id) return row.id } @@ -112,8 +112,7 @@ export async function createOrResolveCollection( updatedBy: input.userId, }) .returning() - const row = inserted.at(0) - if (!row) throw new Error('Insert did not return bag row') + const row = insertedRow(inserted, 'bag') collectionMap?.set(key, row.id) return row.id } @@ -131,8 +130,7 @@ export async function createOrResolveCollection( updatedBy: input.userId, }) .returning() - const row = inserted.at(0) - if (!row) throw new Error('Insert did not return micronix plate row') + const row = insertedRow(inserted, 'micronix plate') collectionMap?.set(key, row.id) return row.id } @@ -149,8 +147,7 @@ export async function createOrResolveCollection( updatedBy: input.userId, }) .returning() - const row = inserted.at(0) - if (!row) throw new Error('Insert did not return cryovial box row') + const row = insertedRow(inserted, 'cryovial box') collectionMap?.set(key, row.id) return row.id } diff --git a/packages/api/src/lib/container-creation.ts b/packages/api/src/lib/container-creation.ts index 0b19fa6..804d23b 100644 --- a/packages/api/src/lib/container-creation.ts +++ b/packages/api/src/lib/container-creation.ts @@ -19,6 +19,7 @@ import { ValidationError } from './error-handler' import type { ContainerWriteInput } from '@sampledb/contract' import { ensureContainerPlacement, type EnsuredPlacement } from './container-write-placement' import { checkGridPositionOccupancy, occupiedGridPositionMessage } from './container-occupancy' +import { insertedRow } from './inserted-row' type DatabaseOrTransaction = | Database @@ -138,10 +139,7 @@ async function insertStorageContainer( updatedBy: userId, }).returning() - const container = inserted[0] - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (inserted.length === 0 || container === undefined) throw new Error('Insert did not return container row') - return { id: container.id } + return { id: insertedRow(inserted, 'container').id } } async function createMicronixTube( diff --git a/packages/api/src/lib/container-write-placement.ts b/packages/api/src/lib/container-write-placement.ts index 9ee0a7f..a1567b2 100644 --- a/packages/api/src/lib/container-write-placement.ts +++ b/packages/api/src/lib/container-write-placement.ts @@ -14,6 +14,7 @@ import { } from './collections/collection-lifecycle' import { utcNow } from './datetime' import { ValidationError } from './error-handler' +import { insertedRow } from './inserted-row' type DatabaseOrTransaction = | Database @@ -321,8 +322,7 @@ async function ensureSheetId( updatedBy: options?.userId, }) .returning() - const row = inserted.at(0) - if (!row) throw new Error('Insert did not return sheet row') + const row = insertedRow(inserted, 'sheet') return row.id } diff --git a/packages/api/src/lib/controls/batch-create.ts b/packages/api/src/lib/controls/batch-create.ts index 629cf89..6f7bae3 100644 --- a/packages/api/src/lib/controls/batch-create.ts +++ b/packages/api/src/lib/controls/batch-create.ts @@ -4,6 +4,7 @@ import { eq, and } from 'drizzle-orm' import { validateControlBatchName, generateUniqueBatchName } from '../validation' import { NotFoundError, ValidationError } from '../error-handler' import type { CreateBloodControlBatchInput } from './batch-schemas' +import { insertedRow } from '../inserted-row' function isUniqueConstraintError(error: unknown): boolean { if (!(error instanceof Error)) return false @@ -53,10 +54,7 @@ export async function createBloodControlBatch( updatedBy: userId, }) .returning() - const newBatch = inserted.at(0) - if (!newBatch) { - throw new Error('Insert did not return control batch row') - } + const newBatch = insertedRow(inserted, 'control batch') return newBatch } catch (error) { if (isUniqueConstraintError(error)) { diff --git a/packages/api/src/lib/controls/batch-with-specimens.ts b/packages/api/src/lib/controls/batch-with-specimens.ts index 7d57eab..2f41e99 100644 --- a/packages/api/src/lib/controls/batch-with-specimens.ts +++ b/packages/api/src/lib/controls/batch-with-specimens.ts @@ -27,6 +27,7 @@ import type { BatchContainerInput } from './batch-schemas' import { withWriteTransaction } from '../../db/write-transaction' import { validateCollectionDate } from '../validation' import { ConflictError, ValidationError, sqliteConstraintConflict } from '../error-handler' +import { insertedRow } from '../inserted-row' export interface CreateBatchWithSpecimensRequest { batch: { @@ -238,10 +239,7 @@ export async function createBatchWithSpecimens( }) .returning() - const batchRecord = batchResult.at(0) - if (!batchRecord) { - throw new Error('Failed to create batch - no record returned') - } + const batchRecord = insertedRow(batchResult, 'control batch') const batchId = extractId(batchRecord) const createdSpecimens: CreatedSpecimen[] = [] @@ -256,10 +254,7 @@ export async function createBatchWithSpecimens( }) .returning() - const specimenRecord = specimenResult.at(0) - if (!specimenRecord) { - throw new Error('Failed to create specimen - no record returned') - } + const specimenRecord = insertedRow(specimenResult, 'specimen') const specimenId = extractId(specimenRecord) const containerIds: number[] = [] @@ -346,10 +341,7 @@ export async function addSpecimensToBatch( }) .returning() - const specimenRecord = specimenResult.at(0) - if (!specimenRecord) { - throw new Error('Failed to create specimen - no record returned') - } + const specimenRecord = insertedRow(specimenResult, 'specimen') specimenId = extractId(specimenRecord) } diff --git a/packages/api/src/lib/crud-routes.ts b/packages/api/src/lib/crud-routes.ts index 36d675d..1144dae 100644 --- a/packages/api/src/lib/crud-routes.ts +++ b/packages/api/src/lib/crud-routes.ts @@ -8,6 +8,7 @@ import { handleRouteError, NotFoundError, ConflictError, ValidationError } from import { listResponse, successResponse, createdResponse } from './response-helpers' import { requireParam } from './common-validators' import { createAdminMiddleware, createAuthMiddleware } from '../middleware/auth' +import { insertedRow } from './inserted-row' export interface CrudRouteConfig< TTable extends SQLiteTable, @@ -230,11 +231,7 @@ export function createCrudRoutes< .values(insertData as any) .returning() - const row = result[0] - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!row) { - throw new Error('Insert did not return row') - } + const row = insertedRow(result, entityName) const transformed = transformDetail ? transformDetail(row as TSelect) : row return createdResponse(c, transformed) } catch (error) { diff --git a/packages/api/src/lib/demo-seed.ts b/packages/api/src/lib/demo-seed.ts index 61f3a25..cecc110 100644 --- a/packages/api/src/lib/demo-seed.ts +++ b/packages/api/src/lib/demo-seed.ts @@ -33,6 +33,7 @@ import { createBatchWithSpecimens } from './controls/batch-with-specimens' import { createDerivation } from './derivations' import { utcNow } from './datetime' import { controlDefinition, box, sheet, micronixPlate, strain } from '../db/schema' +import { insertedRow } from './inserted-row' // Demo defaults (aligned with packages/web setup-defaults, inlined to avoid cross-package deps) const DEMO_SPECIMEN_TYPES = [ @@ -107,9 +108,7 @@ export async function runDemoSeed( .insert(storageType) .values({ id: i + 1, name: s.name, description: s.description }) .returning() - const row = Array.isArray(result) ? result[0] : result - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!row) throw new Error('Insert did not return storage type row') + const row = insertedRow(result, 'storage type') storageTypeMap.set(s.name, row.id) } @@ -265,9 +264,7 @@ export async function runDemoSeed( createdBy: 1, updatedBy: 1, }).returning() - const negDef = Array.isArray(negResult) ? negResult[0] : negResult - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!negDef) throw new Error('Insert did not return control definition row') + const negDef = insertedRow(negResult, 'control definition') const negDefId = negDef.id const plasmaPosResult = await database.insert(controlDefinition).values({ @@ -278,9 +275,7 @@ export async function runDemoSeed( createdBy: 1, updatedBy: 1, }).returning() - const plasmaPosDef = Array.isArray(plasmaPosResult) ? plasmaPosResult[0] : plasmaPosResult - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!plasmaPosDef) throw new Error('Insert did not return control definition row') + const plasmaPosDef = insertedRow(plasmaPosResult, 'control definition') const plasmaPosDefId = plasmaPosDef.id const bloodResult = await database.insert(controlDefinition).values({ @@ -300,9 +295,7 @@ export async function runDemoSeed( createdBy: 1, updatedBy: 1, }).returning() - const bloodDef = Array.isArray(bloodResult) ? bloodResult[0] : bloodResult - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!bloodDef) throw new Error('Insert did not return control definition row') + const bloodDef = insertedRow(bloodResult, 'control definition') const bloodDefId = bloodDef.id const controlBatchResult1 = await createBatchWithSpecimens(database, { @@ -381,9 +374,7 @@ export async function runDemoSeed( created: now, lastUpdated: now, }).returning() - const dbsBox = Array.isArray(dbsBoxResult) ? dbsBoxResult[0] : dbsBoxResult - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!dbsBox) throw new Error('Insert did not return box row') + const dbsBox = insertedRow(dbsBoxResult, 'box') const dbsBoxId = dbsBox.id const dbsSheetResult = await database.insert(sheet).values({ @@ -393,9 +384,7 @@ export async function runDemoSeed( created: now, lastUpdated: now, }).returning() - const dbsSheet = Array.isArray(dbsSheetResult) ? dbsSheetResult[0] : dbsSheetResult - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!dbsSheet) throw new Error('Insert did not return sheet row') + const dbsSheet = insertedRow(dbsSheetResult, 'sheet') const dbsSheetId = dbsSheet.id const bloodDnaPlateResult = await database.insert(micronixPlate).values({ @@ -405,9 +394,7 @@ export async function runDemoSeed( created: now, lastUpdated: now, }).returning() - const bloodDnaPlate = Array.isArray(bloodDnaPlateResult) ? bloodDnaPlateResult[0] : bloodDnaPlateResult - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!bloodDnaPlate) throw new Error('Insert did not return plate row') + const bloodDnaPlate = insertedRow(bloodDnaPlateResult, 'plate') const bloodDnaPlateId = bloodDnaPlate.id const bloodContainerIds = controlBatchResult3.specimens.flatMap((s) => s.containerIds) diff --git a/packages/api/src/lib/derivations.ts b/packages/api/src/lib/derivations.ts index 6c53ea0..e04e051 100644 --- a/packages/api/src/lib/derivations.ts +++ b/packages/api/src/lib/derivations.ts @@ -16,6 +16,7 @@ import { getDefaultUnit } from './defaults' import { utcNow } from './datetime' import { createContainerForSpecimen, pickContainerQuantity } from './container-creation' import { withWriteTransaction } from '../db/write-transaction' +import { insertedRow } from './inserted-row' export type DerivationType = string @@ -122,8 +123,7 @@ async function findOrCreateDerivedSpecimen( lastUpdated: utcNow(), }) .returning() - const created = inserted.at(0) - if (!created) throw new Error('Insert did not return specimen row') + const created = insertedRow(inserted, 'specimen') return created.id } diff --git a/packages/api/src/lib/inserted-row.ts b/packages/api/src/lib/inserted-row.ts new file mode 100644 index 0000000..10b7bca --- /dev/null +++ b/packages/api/src/lib/inserted-row.ts @@ -0,0 +1,9 @@ +/** + * The row an `INSERT … RETURNING` wrote. SQLite always returns it, so an empty result is a bug: + * throw rather than carry on with `undefined`. + */ +export function insertedRow(rows: readonly T[], table: string): T { + const row = rows.at(0) + if (row === undefined) throw new Error(`Insert did not return ${table} row`) + return row +} diff --git a/packages/api/src/lib/registration-orchestrator.ts b/packages/api/src/lib/registration-orchestrator.ts index 8fe45c8..50f2b54 100644 --- a/packages/api/src/lib/registration-orchestrator.ts +++ b/packages/api/src/lib/registration-orchestrator.ts @@ -38,6 +38,7 @@ export { prepareCombinedSubjectContainerBatch, validateSpecimenContainerRegistration, } from './registration-prepare' +import { insertedRow } from './inserted-row' /** * Validate bulk specimen rows without creating records. @@ -117,10 +118,8 @@ export async function createBulkSpecimenRows( updatedBy: userId, }) .returning() - .get() - const inserted = Array.isArray(insertResult) ? insertResult[0] : insertResult - if (!inserted) throw new Error('Insert did not return specimen row') - specimenRecordsByUniqueIndex.push(inserted as typeof specimen.$inferSelect) + .all() + specimenRecordsByUniqueIndex.push(insertedRow(insertResult, 'specimen')) newCount += 1 } } diff --git a/packages/api/src/routes/controls.ts b/packages/api/src/routes/controls.ts index 1e4969a..d7e2371 100644 --- a/packages/api/src/routes/controls.ts +++ b/packages/api/src/routes/controls.ts @@ -46,6 +46,7 @@ import { findMatchingDefinitionInList, buildBloodControlPropertiesPayload, } from '../lib/controls/strain-composition' +import { insertedRow } from '../lib/inserted-row' /** * Create controls routes with database injection @@ -519,10 +520,7 @@ controls.post('/', memberMiddleware, async (c) => { }) .returning() - const newControl = result.at(0) - if (!newControl) { - throw new Error('Insert did not return control definition row') - } + const newControl = insertedRow(result, 'control definition') return c.json({ control: newControl }, 201) } catch (error) { return handleRouteError(error, c) diff --git a/packages/api/src/routes/reagents.ts b/packages/api/src/routes/reagents.ts index 5b4f72a..6e55b57 100644 --- a/packages/api/src/routes/reagents.ts +++ b/packages/api/src/routes/reagents.ts @@ -8,6 +8,7 @@ import { createAuthMiddleware, createMemberMiddleware } from '../middleware/auth import { utcNow } from '../lib/datetime' import { requireParam } from '../lib/common-validators' import { handleRouteError } from '../lib/error-handler' +import { insertedRow } from '../lib/inserted-row' /** * Create reagents routes with database injection @@ -92,19 +93,17 @@ reagents.post('/', memberMiddleware, async (c) => { const data = schema.parse(body) const user = c.get('user') - const [newReagent] = await database - .insert(reagent) - .values({ - ...data, - createdBy: user?.id, - updatedBy: user?.id, - }) - .returning() - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!newReagent) { - throw new Error('Insert did not return reagent row') - } + const newReagent = insertedRow( + await database + .insert(reagent) + .values({ + ...data, + createdBy: user?.id, + updatedBy: user?.id, + }) + .returning(), + 'reagent', + ) return c.json({ reagent: newReagent }, 201) } catch (error) { return handleRouteError(error, c) diff --git a/packages/api/src/routes/specimens.ts b/packages/api/src/routes/specimens.ts index 808b456..55c421b 100644 --- a/packages/api/src/routes/specimens.ts +++ b/packages/api/src/routes/specimens.ts @@ -16,6 +16,7 @@ import { createAuthMiddleware, createMemberMiddleware } from '../middleware/auth import { utcNow } from '../lib/datetime' import { requireParam } from '../lib/common-validators' import { validateBulkSpecimenRows, createBulkSpecimenRows } from '../lib/registration-orchestrator' +import { insertedRow } from '../lib/inserted-row' /** * Create specimens routes with database injection @@ -178,15 +179,13 @@ specimens.post('/', memberMiddleware, async (c) => { insertData.collectionDate = validation.resolved.collectionDate } - const [newSpecimen] = await dbInstance - .insert(specimen) - .values(insertData) - .returning() - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!newSpecimen) { - throw new Error('Insert did not return specimen row') - } + const newSpecimen = insertedRow( + await dbInstance + .insert(specimen) + .values(insertData) + .returning(), + 'specimen', + ) let containerResult: { success: boolean; containerId?: number; error?: string } | null = null if (data.container) { diff --git a/packages/api/src/routes/studies.ts b/packages/api/src/routes/studies.ts index e006ccd..840e7fc 100644 --- a/packages/api/src/routes/studies.ts +++ b/packages/api/src/routes/studies.ts @@ -24,6 +24,7 @@ import { utcNow } from '../lib/datetime' import { requireParam } from '../lib/common-validators' import { withWriteTransaction } from '../db/write-transaction' import { assertNotUsedInQpcr } from '../lib/qpcr-usage' +import { insertedRow } from '../lib/inserted-row' /** Short code prefix for tutorial namespace. Any study whose short code starts with this (case-insensitive) may be deleted by any member. Only admins may rename a study into or out of this namespace. */ const TUTORIAL_SHORT_CODE_PREFIX = 'TUT' @@ -578,21 +579,19 @@ studies.post('/', memberMiddleware, async (c) => { const data = schema.parse(body) const user = c.get('user') - const [newStudy] = await database - .insert(study) - .values({ - ...data, - created: utcNow(), - lastUpdated: utcNow(), - createdBy: user?.id, - updatedBy: user?.id, - }) - .returning() - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime invariant per avoid-masking-bugs: insert must return row - if (!newStudy) { - throw new Error('Insert did not return study row') - } + const newStudy = insertedRow( + await database + .insert(study) + .values({ + ...data, + created: utcNow(), + lastUpdated: utcNow(), + createdBy: user?.id, + updatedBy: user?.id, + }) + .returning(), + 'study', + ) return c.json({ study: newStudy }, 201) } catch (error) { return handleRouteError(error, c) From 4a9cca4bff04fba5e9586b342d3216e307af24b1 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:55:25 -0700 Subject: [PATCH 31/85] refactor(export): replace the 'all' subject-date sentinel with a SubjectDateSelection union. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/export/query.ts | 22 +++++++++++++--------- packages/contract/src/export.ts | 3 +++ packages/contract/src/index.ts | 1 + 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index 8be1f82..104ba35 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -11,7 +11,7 @@ import { resolveSubjectsByStudyGrouped } from '../identifier-resolution' import { resolveContainerIdsWithAllTags } from '../container-tag-filter' import { enrichContainerData } from './enrich' import { filterContainerIdsByType } from './filter' -import type { SubjectDateFilter } from '@sampledb/contract' +import type { SubjectDateFilter, SubjectDateSelection } from '@sampledb/contract' import type { ContainerExportData, ExportFilters, @@ -345,20 +345,23 @@ export async function buildMultiStudyContainerQuery( // Build subject dates map (by study and subject name) // A subject may appear on several rows (one per visit); keep every date. A row without // a date means all of that subject's dates, so it wins over dated rows. - const subjectDatesByStudy = new Map>() + const subjectDatesByStudy = new Map>() for (const [studyId, studyEntries] of entriesByStudy.entries()) { - const datesMap = new Map() + const datesMap = new Map() for (const entry of studyEntries) { const existing = datesMap.get(entry.subject_name) - if (existing === 'all') continue + if (existing?.kind === 'all') continue const dateFilter: SubjectDateFilter | null = entry.collection_date ? { exact: entry.collection_date } : entry.date_from || entry.date_to ? { from: entry.date_from, to: entry.date_to } : null - datesMap.set(entry.subject_name, dateFilter ? [...(existing ?? []), dateFilter] : 'all') + datesMap.set( + entry.subject_name, + dateFilter ? { kind: 'dates', filters: [...(existing?.filters ?? []), dateFilter] } : { kind: 'all' }, + ) } - if ([...datesMap.values()].some((v) => v !== 'all')) { + if ([...datesMap.values()].some((selection) => selection.kind === 'dates')) { subjectDatesByStudy.set(studyId, datesMap) } } @@ -399,10 +402,11 @@ export async function buildMultiStudyContainerQuery( const studySubjectDates = subjectDatesByStudy.get(studyId) if (studySubjectDates && studySubjectDates.size > 0) { studyFilters.subject_dates = {} - for (const [subjectName, dateFilters] of studySubjectDates.entries()) { + for (const [subjectName, selection] of studySubjectDates.entries()) { const subjectId = subjectMap.get(subjectName) - if (subjectId && dateFilters !== 'all') { - studyFilters.subject_dates[subjectId] = dateFilters + // Subjects with no entry match every date. + if (subjectId && selection.kind === 'dates') { + studyFilters.subject_dates[subjectId] = selection.filters } } } diff --git a/packages/contract/src/export.ts b/packages/contract/src/export.ts index 6eab6c4..03a7367 100644 --- a/packages/contract/src/export.ts +++ b/packages/contract/src/export.ts @@ -27,6 +27,9 @@ export interface ExportFilters { export type SubjectDateFilter = { exact: string } | { from?: string; to?: string } +/** Which collection dates to export for one subject: all of them, or those matching any filter. */ +export type SubjectDateSelection = { kind: 'all' } | { kind: 'dates'; filters: SubjectDateFilter[] } + export interface ContainerExportData { container_id: number container_type: string diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index a25ff94..fd3c984 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -10,6 +10,7 @@ export { type ContainerExportData, type ExportFilters, type SubjectDateFilter, + type SubjectDateSelection, } from './export' export { DEFAULT_EXPORT_COLUMN_KEYS, From 05ce00b84f0697f833d58ee9eb9157664db63dcc Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:55:36 -0700 Subject: [PATCH 32/85] refactor(export): keep the "no date means all dates" rule in the contract. The web export modal and the multi-study API path both folded CSV rows into per-subject date filters; both now call subjectDateSelections. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/export/query.ts | 25 ++++-------------- .../src/__tests__/export-filter-parse.test.ts | 26 +++++++++++++++++++ packages/contract/src/export-filter-parse.ts | 24 +++++++++++++++++ packages/contract/src/index.ts | 1 + .../src/hooks/useSingleStudyExportWorkflow.ts | 21 ++++----------- 5 files changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index 104ba35..eab90c2 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -11,7 +11,7 @@ import { resolveSubjectsByStudyGrouped } from '../identifier-resolution' import { resolveContainerIdsWithAllTags } from '../container-tag-filter' import { enrichContainerData } from './enrich' import { filterContainerIdsByType } from './filter' -import type { SubjectDateFilter, SubjectDateSelection } from '@sampledb/contract' +import { subjectDateSelections, type SubjectDateFilter, type SubjectDateSelection } from '@sampledb/contract' import type { ContainerExportData, ExportFilters, @@ -342,27 +342,12 @@ export async function buildMultiStudyContainerQuery( const subjectsByStudy = await resolveSubjectsByStudyGrouped(database, subjectResolutionEntries) - // Build subject dates map (by study and subject name) - // A subject may appear on several rows (one per visit); keep every date. A row without - // a date means all of that subject's dates, so it wins over dated rows. + // Date selection per subject name, for studies where at least one subject is limited to some dates const subjectDatesByStudy = new Map>() for (const [studyId, studyEntries] of entriesByStudy.entries()) { - const datesMap = new Map() - for (const entry of studyEntries) { - const existing = datesMap.get(entry.subject_name) - if (existing?.kind === 'all') continue - const dateFilter: SubjectDateFilter | null = entry.collection_date - ? { exact: entry.collection_date } - : entry.date_from || entry.date_to - ? { from: entry.date_from, to: entry.date_to } - : null - datesMap.set( - entry.subject_name, - dateFilter ? { kind: 'dates', filters: [...(existing?.filters ?? []), dateFilter] } : { kind: 'all' }, - ) - } - if ([...datesMap.values()].some((selection) => selection.kind === 'dates')) { - subjectDatesByStudy.set(studyId, datesMap) + const selections = subjectDateSelections(studyEntries) + if ([...selections.values()].some((selection) => selection.kind === 'dates')) { + subjectDatesByStudy.set(studyId, selections) } } diff --git a/packages/contract/src/__tests__/export-filter-parse.test.ts b/packages/contract/src/__tests__/export-filter-parse.test.ts index 43a2be4..f75dfd2 100644 --- a/packages/contract/src/__tests__/export-filter-parse.test.ts +++ b/packages/contract/src/__tests__/export-filter-parse.test.ts @@ -3,6 +3,7 @@ import { parseBarcodeExportFilterCsv, parseMultiStudyExportFilterCsv, parseSingleStudyExportFilterCsv, + subjectDateSelections, } from '../export-filter-parse' describe('parseMultiStudyExportFilterCsv', () => { @@ -60,3 +61,28 @@ describe('parseBarcodeExportFilterCsv', () => { expect(() => parseBarcodeExportFilterCsv('position\nA01')).toThrow(/barcode/) }) }) + +describe('subjectDateSelections', () => { + it('keeps every date for a subject listed once per visit', () => { + const selections = subjectDateSelections([ + { subject_name: 'A', collection_date: '2024-01-01' }, + { subject_name: 'A', date_from: '2024-02-01', date_to: '2024-02-28' }, + ]) + expect(selections.get('A')).toEqual({ + kind: 'dates', + filters: [{ exact: '2024-01-01' }, { from: '2024-02-01', to: '2024-02-28' }], + }) + }) + + it('selects all dates when any row for the subject has no date, in either order', () => { + const selections = subjectDateSelections([ + { subject_name: 'A', collection_date: '2024-01-01' }, + { subject_name: 'A' }, + { subject_name: 'B' }, + { subject_name: 'B', collection_date: '2024-01-01' }, + ]) + expect(selections.get('A')).toEqual({ kind: 'all' }) + expect(selections.get('B')).toEqual({ kind: 'all' }) + }) +}) + diff --git a/packages/contract/src/export-filter-parse.ts b/packages/contract/src/export-filter-parse.ts index 936aea2..3035f10 100644 --- a/packages/contract/src/export-filter-parse.ts +++ b/packages/contract/src/export-filter-parse.ts @@ -1,4 +1,5 @@ import { parseCsv } from './csv' +import type { SubjectDateFilter, SubjectDateSelection } from './export' export interface ExportFilterColumnSpec { key: string @@ -199,3 +200,26 @@ export function parseBarcodeExportFilterCsv(csvText: string): string[] { return barcodes } + +/** + * Fold subject-list filter rows into one collection-date selection per subject name. + * A subject on several rows keeps every date (one per visit). A row with no date selects + * all of that subject's dates, and wins over the subject's dated rows. + */ +export function subjectDateSelections(rows: SingleStudyExportFilterRow[]): Map { + const selections = new Map() + for (const row of rows) { + const existing = selections.get(row.subject_name) + if (existing?.kind === 'all') continue + const dateFilter: SubjectDateFilter | null = row.collection_date + ? { exact: row.collection_date } + : row.date_from || row.date_to + ? { from: row.date_from, to: row.date_to } + : null + selections.set( + row.subject_name, + dateFilter ? { kind: 'dates', filters: [...(existing?.filters ?? []), dateFilter] } : { kind: 'all' }, + ) + } + return selections +} diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index fd3c984..1bf79f0 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -27,6 +27,7 @@ export { parseBarcodeExportFilterCsv, parseMultiStudyExportFilterCsv, parseSingleStudyExportFilterCsv, + subjectDateSelections, type ExportFilterColumnSpec, type MultiStudyExportFilterRow, type SingleStudyExportFilterRow, diff --git a/packages/web/src/hooks/useSingleStudyExportWorkflow.ts b/packages/web/src/hooks/useSingleStudyExportWorkflow.ts index 9b655e3..72f7629 100644 --- a/packages/web/src/hooks/useSingleStudyExportWorkflow.ts +++ b/packages/web/src/hooks/useSingleStudyExportWorkflow.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { exportApi, type ExportFilters } from '../lib/api/export' -import type { SubjectDateFilter } from '@sampledb/contract' +import { subjectDateSelections, type SubjectDateFilter } from '@sampledb/contract' import { parseExportModalCsv, type SingleStudyExportCsvRow } from '../lib/export-filter-csv' import { formatLocalDateTime } from '../lib/date-utils' import { toggleArrayFilterValue } from '../lib/filter-array-toggle' @@ -18,25 +18,14 @@ export type SingleStudyExportSubmitParams = { } /** - * Per-subject date filters from the uploaded CSV. A subject on several rows keeps every - * date (one per visit); a row without a date means all of that subject's dates. + * Per-subject date filters for the API from the uploaded CSV. Subjects whose rows select + * all dates are left out: the API matches every date for a listed subject with no entry. */ export function buildSubjectDatesFromCsv(csvData: SingleStudyExportCsvRow[]) { const subjectDates: { [key: string]: SubjectDateFilter[] } = {} - const allDates = new Set() - for (const row of csvData) { - const dateFilter: SubjectDateFilter | null = row.collection_date - ? { exact: row.collection_date } - : row.date_from || row.date_to - ? { from: row.date_from, to: row.date_to } - : null - if (!dateFilter) { - allDates.add(row.subject_name) - continue - } - ;(subjectDates[row.subject_name] ??= []).push(dateFilter) + for (const [name, selection] of subjectDateSelections(csvData)) { + if (selection.kind === 'dates') subjectDates[name] = selection.filters } - for (const name of allDates) delete subjectDates[name] return Object.keys(subjectDates).length > 0 ? subjectDates : undefined } From dd7348813fb550f30d329a9c68015a204d67bf33 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:55:40 -0700 Subject: [PATCH 33/85] fix(imports): keep every error on a bulk combined 409. A full_file import whose write hit a constraint threw ConflictError with the first message only, while its 400 carried details.errors. ConflictError now takes details and the route handler returns them, so the 409 lists every error too, and /subjects/with-specimens does the same. failInsertsWithUniqueViolation lets a test reach that path: a trigger makes the insert fail as if a concurrent request took the row. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/helpers/constraint-race.ts | 17 +++++++++++++++++ packages/api/src/lib/bulk-combined-import.ts | 7 ++++--- packages/api/src/lib/error-handler.ts | 5 +++-- .../api/src/routes/__tests__/imports.test.ts | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/__tests__/helpers/constraint-race.ts diff --git a/packages/api/src/__tests__/helpers/constraint-race.ts b/packages/api/src/__tests__/helpers/constraint-race.ts new file mode 100644 index 0000000..0a26c6d --- /dev/null +++ b/packages/api/src/__tests__/helpers/constraint-race.ts @@ -0,0 +1,17 @@ +import type { Database as SQLiteDatabase } from 'bun:sqlite' + +/** + * Make every insert into `table` fail with a UNIQUE violation, the way a write fails when a + * concurrent request took the row after the plan checked it. Use it to reach the 409 path of a + * write that validates first. + */ +export function failInsertsWithUniqueViolation(sqlite: SQLiteDatabase, table: string): void { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS test_constraint_race (taken TEXT UNIQUE); + INSERT OR IGNORE INTO test_constraint_race VALUES ('row'); + CREATE TRIGGER test_constraint_race_${table} BEFORE INSERT ON ${table} + BEGIN + INSERT INTO test_constraint_race VALUES ('row'); + END; + `) +} diff --git a/packages/api/src/lib/bulk-combined-import.ts b/packages/api/src/lib/bulk-combined-import.ts index f4563ec..dd88c7f 100644 --- a/packages/api/src/lib/bulk-combined-import.ts +++ b/packages/api/src/lib/bulk-combined-import.ts @@ -250,8 +250,9 @@ export async function runOneSubjectWithSpecimens( return result } catch (error) { if (!(error instanceof BulkCombinedRejected)) throw error - if (error.status === 409) throw new ConflictError(error.message) - throw new ValidationError(error.message, { specimenIndex: error.errors[0]?.specimenIndex, errors: error.errors }) + const details = { specimenIndex: error.errors[0]?.specimenIndex, errors: error.errors } + if (error.status === 409) throw new ConflictError(error.message, details) + throw new ValidationError(error.message, details) } } @@ -297,7 +298,7 @@ export async function runBulkCombinedImport( if (!(error instanceof BulkCombinedRejected)) throw error const first = error.errors.at(0) const where = first ? `Subject '${subjects[first.subjectIndex].subjectName.trim()}' (index ${first.subjectIndex + 1}) failed: ` : '' - if (error.status === 409) throw new ConflictError(`${where}${error.message}`) + if (error.status === 409) throw new ConflictError(`${where}${error.message}`, { errors: error.errors }) throw new ValidationError(`${where}${error.message}`, { errors: error.errors }) } } diff --git a/packages/api/src/lib/error-handler.ts b/packages/api/src/lib/error-handler.ts index 6afbdd7..504c398 100644 --- a/packages/api/src/lib/error-handler.ts +++ b/packages/api/src/lib/error-handler.ts @@ -124,7 +124,8 @@ export function handleRouteError(error: unknown, c: Context): Response { logBackendError(database, error, errorContext).catch(logPersistFailure) return c.json({ error: error.message, - errorCode: 'CONFLICT' + errorCode: 'CONFLICT', + ...(error.details !== undefined && { details: error.details }), }, 409) } @@ -203,7 +204,7 @@ export class NotFoundError extends Error { } export class ConflictError extends Error { - constructor(message: string) { + constructor(message: string, public details?: unknown) { super(message) this.name = 'ConflictError' } diff --git a/packages/api/src/routes/__tests__/imports.test.ts b/packages/api/src/routes/__tests__/imports.test.ts index d077e22..48c5cb0 100644 --- a/packages/api/src/routes/__tests__/imports.test.ts +++ b/packages/api/src/routes/__tests__/imports.test.ts @@ -15,6 +15,7 @@ import { import { studySubject, specimen, specimenTypeContainerType, containerTypeUnit, micronixPlate, cryovialBox, settings, storageContainer } from '../../db/schema' import { setContainerDefaults } from '../../lib/settings' import { eq } from 'drizzle-orm' +import { failInsertsWithUniqueViolation } from '../../__tests__/helpers/constraint-race' describe('Imports API', () => { let ctx: AuthenticatedRouteTestContext @@ -366,6 +367,24 @@ describe('Imports API', () => { expect(created).toBeDefined() expect(created!.unitId).toBe(testUnit.id) }) + it('returns 409 with every error when a full_file write hits a constraint', async () => { + await createTestStudy(ctx.db, { title: 'Race Study', shortCode: 'RACE' }) + await createTestSpecimenType(ctx.db, { name: 'Whole Blood' }) + failInsertsWithUniqueViolation(ctx.sqlite, 'specimen') + + const res = await ctx.request('/api/imports/bulk-combined', { + method: 'POST', + json: { + studyShortCode: 'RACE', + atomicMode: 'full_file', + subjects: [{ subjectName: 'SUBJ1', specimens: [{ specimenTypeName: 'Whole Blood', collectionDate: '2025-01-01' }] }], + }, + }) + + expect(res.status).toBe(409) + const body = (await res.json()) as { details?: { errors?: unknown[] } } + expect(body.details?.errors).toEqual([expect.objectContaining({ subjectIndex: 0, specimenIndex: 0 })]) + }) }) describe('POST /api/imports/bulk-combined/validate', () => { From 0fbb943b95649557c61e6d5d89e237a828ee95a0 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:56:25 -0700 Subject: [PATCH 34/85] refactor(auth): name the bcrypt cost once as BCRYPT_COST. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/__tests__/helpers/auth-helpers.ts | 3 ++- packages/api/src/lib/auth/auth-service.ts | 7 +++++-- packages/api/src/lib/demo-seed.ts | 3 ++- packages/api/src/routes/__tests__/auth.test.ts | 5 +++-- packages/api/src/routes/auth.ts | 7 ++++--- packages/api/src/routes/setup.ts | 3 ++- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/api/src/__tests__/helpers/auth-helpers.ts b/packages/api/src/__tests__/helpers/auth-helpers.ts index a66d59a..f46ffd5 100644 --- a/packages/api/src/__tests__/helpers/auth-helpers.ts +++ b/packages/api/src/__tests__/helpers/auth-helpers.ts @@ -1,6 +1,7 @@ import type { Database } from '../../db/client' import { users, sessions, settings } from '../../db/schema' import bcrypt from 'bcryptjs' +import { BCRYPT_COST } from '../../lib/auth/auth-service' import { nanoid } from 'nanoid' import type { Hono } from 'hono' import { testClient } from 'hono/testing' @@ -28,7 +29,7 @@ export async function createTestUser( db: Database, options: CreateTestUserOptions ) { - const passwordHash = await bcrypt.hash(options.password || 'password123', 10) + const passwordHash = await bcrypt.hash(options.password || 'password123', BCRYPT_COST) const createdAt = utcNow() const approvedAt = options.approvedAt !== undefined ? options.approvedAt : createdAt diff --git a/packages/api/src/lib/auth/auth-service.ts b/packages/api/src/lib/auth/auth-service.ts index 118bc77..c924ddf 100644 --- a/packages/api/src/lib/auth/auth-service.ts +++ b/packages/api/src/lib/auth/auth-service.ts @@ -45,6 +45,9 @@ export async function findActiveUserByEmailOrUsername( ) } +/** bcrypt cost for every stored password hash, and for the throwaway hash in the login timing guard. */ +export const BCRYPT_COST = 10 + let dummyPasswordHash: string | null = null /** @@ -53,7 +56,7 @@ let dummyPasswordHash: string | null = null */ export async function verifyPasswordConstantTime(password: string, passwordHash: string | undefined): Promise { if (passwordHash) return bcrypt.compare(password, passwordHash) - dummyPasswordHash ??= await bcrypt.hash('no-such-user', 10) + dummyPasswordHash ??= await bcrypt.hash('no-such-user', BCRYPT_COST) await bcrypt.compare(password, dummyPasswordHash) return false } @@ -117,7 +120,7 @@ export async function registerApprovedUser( throw new ValidationError('Username already in use') } } - const passwordHash = await bcrypt.hash(data.password, 10) + const passwordHash = await bcrypt.hash(data.password, BCRYPT_COST) const createdAt = utcNow() const [user] = await database .insert(users) diff --git a/packages/api/src/lib/demo-seed.ts b/packages/api/src/lib/demo-seed.ts index 61f3a25..4067158 100644 --- a/packages/api/src/lib/demo-seed.ts +++ b/packages/api/src/lib/demo-seed.ts @@ -20,6 +20,7 @@ import { } from '../db/schema' import { sql, eq } from 'drizzle-orm' import bcrypt from 'bcryptjs' +import { BCRYPT_COST } from './auth/auth-service' import { setContainerDefaults, setPaginationSettings, @@ -87,7 +88,7 @@ export async function runDemoSeed( } // 1. Create admin user - const passwordHash = await bcrypt.hash(adminPassword, 10) + const passwordHash = await bcrypt.hash(adminPassword, BCRYPT_COST) await database.insert(users).values({ id: 1, name: 'Demo Admin', diff --git a/packages/api/src/routes/__tests__/auth.test.ts b/packages/api/src/routes/__tests__/auth.test.ts index 4f94866..02e98dd 100644 --- a/packages/api/src/routes/__tests__/auth.test.ts +++ b/packages/api/src/routes/__tests__/auth.test.ts @@ -8,6 +8,7 @@ import { setupTestDatabase, cleanupTestDatabase } from '../../__tests__/helpers/ import { users, sessions } from '../../db/schema' import { eq } from 'drizzle-orm' import bcrypt from 'bcryptjs' +import { BCRYPT_COST } from '../../lib/auth/auth-service' import type { Database } from '../../db/client' import { createTestUser, @@ -139,7 +140,7 @@ describe('Auth API', () => { beforeEach(async () => { // Create a test user (approved so they can log in) const createdAt = utcNow() - const passwordHash = await bcrypt.hash('password123', 10) + const passwordHash = await bcrypt.hash('password123', BCRYPT_COST) await testDb.insert(users).values({ email: 'login@example.com', name: 'Login Test User', @@ -393,7 +394,7 @@ describe('Auth API', () => { email: 'squatter@example.com', name: 'Squatter', username: 'victim@example.com', - passwordHash: await bcrypt.hash('squatter123', 10), + passwordHash: await bcrypt.hash('squatter123', BCRYPT_COST), role: 'member', createdAt: utcNow(), approvedAt: utcNow(), diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index c53df0c..2e25ed1 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -16,6 +16,7 @@ import { verifyPasswordConstantTime, createUserSession, registerApprovedUser, + BCRYPT_COST, } from '../lib/auth/auth-service' function publicUserResponse(user: ReturnType) { @@ -155,7 +156,7 @@ auth.post('/self-register', rateLimit(5, 60 * 1000), async (c) => { } } - const passwordHash = await bcrypt.hash(data.password, 10) + const passwordHash = await bcrypt.hash(data.password, BCRYPT_COST) const createdAt = utcNow() const [user] = await database @@ -374,7 +375,7 @@ auth.patch('/me/password', authMiddleware, async (c) => { } // Hash new password - const passwordHash = await bcrypt.hash(newPassword, 10) + const passwordHash = await bcrypt.hash(newPassword, BCRYPT_COST) await database .update(users) @@ -715,7 +716,7 @@ auth.patch('/users/:id/password', adminMiddleware, async (c) => { } // Hash new password - const passwordHash = await bcrypt.hash(password, 10) + const passwordHash = await bcrypt.hash(password, BCRYPT_COST) await database .update(users) diff --git a/packages/api/src/routes/setup.ts b/packages/api/src/routes/setup.ts index 2f78fbd..cb84c9c 100644 --- a/packages/api/src/routes/setup.ts +++ b/packages/api/src/routes/setup.ts @@ -3,6 +3,7 @@ import { users, specimenType, unit, storageType, location, strain, specimenTypeC import { sql, eq } from 'drizzle-orm' import { z } from 'zod' import bcrypt from 'bcryptjs' +import { BCRYPT_COST } from '../lib/auth/auth-service' import { rateLimit } from '../middleware/rate-limit' import { setContainerDefaults, @@ -110,7 +111,7 @@ const initSchema = z.object({ } // Hash outside the transaction: bcrypt yields to the event loop. - const passwordHash = await bcrypt.hash(adminPassword, 10) + const passwordHash = await bcrypt.hash(adminPassword, BCRYPT_COST) await withWriteTransaction(database, async (database) => { // Re-check inside the transaction so concurrent setups cannot both proceed From ff791a1a8acc75efd0c90e9c5c2206189a3ce554 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:56:36 -0700 Subject: [PATCH 35/85] docs(api): put the dashboard statistics doc comment on its function. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/statistics/dashboard-stats.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/lib/statistics/dashboard-stats.ts b/packages/api/src/lib/statistics/dashboard-stats.ts index f9676a4..1e1d076 100644 --- a/packages/api/src/lib/statistics/dashboard-stats.ts +++ b/packages/api/src/lib/statistics/dashboard-stats.ts @@ -22,7 +22,7 @@ import { computeSpecimenAggregates } from './specimen-aggregates' import { computeStorageStatistics } from './storage-aggregates' import type { DashboardStatistics, StatisticsFilters } from './types' -/** Filtered dashboard statistics for specimens, containers, and storage. */ +/** Statistics with every count zero, for filters that match nothing. */ function emptyDashboardStatistics(): DashboardStatistics { return { specimens: { @@ -47,6 +47,7 @@ function emptyDashboardStatistics(): DashboardStatistics { } } +/** Filtered dashboard statistics for specimens, containers, and storage. */ export async function getDashboardStatistics( database: Database, sqliteDatabase: SQLiteDatabase, From c7d696e72c787f35421848ac6b206082b65b371b Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:56:42 -0700 Subject: [PATCH 36/85] refactor(api): branch on every collection type explicitly when deleting a collection. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/src/lib/collection-delete-cascade.ts | 183 ++++++++---------- .../src/routes/__tests__/collections.test.ts | 68 +++++++ 2 files changed, 154 insertions(+), 97 deletions(-) diff --git a/packages/api/src/lib/collection-delete-cascade.ts b/packages/api/src/lib/collection-delete-cascade.ts index 776578f..82127e7 100644 --- a/packages/api/src/lib/collection-delete-cascade.ts +++ b/packages/api/src/lib/collection-delete-cascade.ts @@ -49,71 +49,98 @@ export interface DeleteCollectionWithContentsResult { subjectsDeleted: number } -/** Collect all storage_container ids in this collection (tubes, wells, papers, etc.) */ -export async function collectContainerIdsInCollection( - database: Database, - type: CollectionEntityType, - id: number -): Promise { - if (type === 'micronix_plate') { - const [tubeRows, wellRows] = await Promise.all([ - database - .select({ id: micronixTube.id }) - .from(micronixTube) - .where(eq(micronixTube.collectionId, id)) - .all(), - database - .select({ id: staticWell.id }) - .from(staticWell) - .where(eq(staticWell.collectionId, id)) - .all(), - ]) - return [...tubeRows.map((x) => x.id), ...wellRows.map((x) => x.id)] - } - if (type === 'cryovial_box') { - const rows = await database - .select({ id: cryovialTube.id }) - .from(cryovialTube) - .where(eq(cryovialTube.collectionId, id)) - .all() - return rows.map((x) => x.id) - } - if (type === 'box') { - const rows = await database - .select({ id: paper.id }) - .from(paper) - .innerJoin(sheet, eq(paper.sheetId, sheet.id)) - .where(eq(sheet.boxId, id)) - .all() - return rows.map((x) => x.id) - } - const rows = await database +/** Per-kind table access. A Record, so adding a CollectionEntityType fails to compile until it has an entry. */ +interface CollectionKind { + label: string + /** All storage_container ids in the collection (tubes, wells, papers). */ + containerIds(database: Database, id: number): number[] + exists(database: Database, id: number): boolean + /** Deletes the collection row (and a box's or bag's sheets); returns the number of sheets deleted. */ + deleteRow(database: Database, id: number): number +} + +function paperIdsOnSheets(database: Database, sheetColumn: typeof sheet.boxId | typeof sheet.bagId, id: number): number[] { + return database .select({ id: paper.id }) .from(paper) .innerJoin(sheet, eq(paper.sheetId, sheet.id)) - .where(eq(sheet.bagId, id)) + .where(eq(sheetColumn, id)) .all() - return rows.map((x) => x.id) + .map((x) => x.id) +} + +function deleteSheets(database: Database, sheetColumn: typeof sheet.boxId | typeof sheet.bagId, id: number): number { + return database.delete(sheet).where(eq(sheetColumn, id)).returning({ id: sheet.id }).all().length +} + +const COLLECTION_KINDS: Record = { + micronix_plate: { + label: 'Micronix plate', + containerIds: (database, id) => [ + ...database.select({ id: micronixTube.id }).from(micronixTube).where(eq(micronixTube.collectionId, id)).all(), + ...database.select({ id: staticWell.id }).from(staticWell).where(eq(staticWell.collectionId, id)).all(), + ].map((x) => x.id), + exists: (database, id) => + database.select({ id: micronixPlate.id }).from(micronixPlate).where(eq(micronixPlate.id, id)).get() != null, + deleteRow: (database, id) => { + requireDeleted(database.delete(micronixPlate).where(eq(micronixPlate.id, id)).returning().all(), 'Micronix plate', id) + return 0 + }, + }, + cryovial_box: { + label: 'Cryovial box', + containerIds: (database, id) => + database + .select({ id: cryovialTube.id }) + .from(cryovialTube) + .where(eq(cryovialTube.collectionId, id)) + .all() + .map((x) => x.id), + exists: (database, id) => + database.select({ id: cryovialBox.id }).from(cryovialBox).where(eq(cryovialBox.id, id)).get() != null, + deleteRow: (database, id) => { + requireDeleted(database.delete(cryovialBox).where(eq(cryovialBox.id, id)).returning().all(), 'Cryovial box', id) + return 0 + }, + }, + box: { + label: 'Box', + containerIds: (database, id) => paperIdsOnSheets(database, sheet.boxId, id), + exists: (database, id) => database.select({ id: box.id }).from(box).where(eq(box.id, id)).get() != null, + deleteRow: (database, id) => { + const sheetsDeleted = deleteSheets(database, sheet.boxId, id) + requireDeleted(database.delete(box).where(eq(box.id, id)).returning().all(), 'Box', id) + return sheetsDeleted + }, + }, + bag: { + label: 'Bag', + containerIds: (database, id) => paperIdsOnSheets(database, sheet.bagId, id), + exists: (database, id) => database.select({ id: bag.id }).from(bag).where(eq(bag.id, id)).get() != null, + deleteRow: (database, id) => { + const sheetsDeleted = deleteSheets(database, sheet.bagId, id) + requireDeleted(database.delete(bag).where(eq(bag.id, id)).returning().all(), 'Bag', id) + return sheetsDeleted + }, + }, +} + +function requireDeleted(rows: unknown[], label: string, id: number): void { + if (rows.length === 0) throw new NotFoundError(label, id) } -async function ensureCollectionRowExists( +/** Collect all storage_container ids in this collection (tubes, wells, papers, etc.) */ +export async function collectContainerIdsInCollection( database: Database, type: CollectionEntityType, id: number -): Promise { - if (type === 'micronix_plate') { - const p = await database.select().from(micronixPlate).where(eq(micronixPlate.id, id)).get() - if (!p) throw new NotFoundError('Micronix plate', id) - } else if (type === 'cryovial_box') { - const b = await database.select().from(cryovialBox).where(eq(cryovialBox.id, id)).get() - if (!b) throw new NotFoundError('Cryovial box', id) - } else if (type === 'box') { - const b = await database.select().from(box).where(eq(box.id, id)).get() - if (!b) throw new NotFoundError('Box', id) - } else { - const b = await database.select().from(bag).where(eq(bag.id, id)).get() - if (!b) throw new NotFoundError('Bag', id) - } +): Promise { + return COLLECTION_KINDS[type].containerIds(database, id) +} + +function ensureCollectionRowExists(database: Database, type: CollectionEntityType, id: number): void { + const kind = COLLECTION_KINDS[type] + if (!kind.exists(database, id)) throw new NotFoundError(kind.label, id) } /** @@ -291,7 +318,7 @@ export async function preflightCollectionDelete( database: Database, request: Pick ): Promise { - await ensureCollectionRowExists(database, request.type, request.id) + ensureCollectionRowExists(database, request.type, request.id) const containerIdList = await collectContainerIdsInCollection(database, request.type, request.id) const containerIdSet = new Set(containerIdList) @@ -318,7 +345,7 @@ export async function deleteCollectionWithContents( ): Promise { const { type, id, removeEmptySubjects } = request - await ensureCollectionRowExists(database, type, id) + ensureCollectionRowExists(database, type, id) const containerIdList = await collectContainerIdsInCollection(database, type, id) const containerIdSet = new Set(containerIdList) @@ -395,45 +422,7 @@ export async function deleteCollectionWithContents( } } - if (type === 'box') { - const sheetRows = await tx - .select({ id: sheet.id }) - .from(sheet) - .where(eq(sheet.boxId, id)) - .all() - sheetsDeleted = sheetRows.length - if (sheetRows.length > 0) { - tx.delete(sheet).where(eq(sheet.boxId, id)).run() - } - const boxDel = await tx.delete(box).where(eq(box.id, id)).returning() - if (boxDel.length === 0) { - throw new NotFoundError('Box', id) - } - } else if (type === 'bag') { - const sheetRows = await tx - .select({ id: sheet.id }) - .from(sheet) - .where(eq(sheet.bagId, id)) - .all() - sheetsDeleted = sheetRows.length - if (sheetRows.length > 0) { - tx.delete(sheet).where(eq(sheet.bagId, id)).run() - } - const bagDel = await tx.delete(bag).where(eq(bag.id, id)).returning() - if (bagDel.length === 0) { - throw new NotFoundError('Bag', id) - } - } else if (type === 'micronix_plate') { - const plateDel = await tx.delete(micronixPlate).where(eq(micronixPlate.id, id)).returning() - if (plateDel.length === 0) { - throw new NotFoundError('Micronix plate', id) - } - } else { - const cryoDel = await tx.delete(cryovialBox).where(eq(cryovialBox.id, id)).returning() - if (cryoDel.length === 0) { - throw new NotFoundError('Cryovial box', id) - } - } + sheetsDeleted = COLLECTION_KINDS[type].deleteRow(tx, id) }) return { diff --git a/packages/api/src/routes/__tests__/collections.test.ts b/packages/api/src/routes/__tests__/collections.test.ts index c7d594f..205e5f6 100644 --- a/packages/api/src/routes/__tests__/collections.test.ts +++ b/packages/api/src/routes/__tests__/collections.test.ts @@ -22,6 +22,11 @@ import { storageContainer, micronixTube, box, + bag, + sheet, + paper, + cryovialBox, + cryovialTube, micronixPlate, specimen, qpcrExperiment, @@ -336,6 +341,69 @@ describe('Collections API', () => { expect(res.status).toBe(404) }) + it.each(['micronix_plate', 'cryovial_box', 'box', 'bag'] as const)( + 'returns 404 when the %s does not exist', + async (collectionType) => { + const res = await ctx.request('/api/collections/delete-with-contents', { + method: 'POST', + json: { collectionType, id: 999_999, removeEmptySubjects: false }, + }) + expect(res.status).toBe(404) + }, + ) + + it.each(['cryovial_box', 'box', 'bag'] as const)( + 'deletes a %s with its container, specimen and sheets', + async (collectionType) => { + const storageType = await createTestStorageType(ctx.db, { name: `St-${collectionType}` }) + const loc = await createTestLocation(ctx.db, { + name: `Loc-${collectionType}`, + storageTypeId: String(storageType.id), + canContainCollections: true, + }) + const st = await createTestSpecimenType(ctx.db, { name: `ST-${collectionType}` }) + const sp = await createTestSpecimen(ctx.db, st.id) + const unit = await createTestUnit(ctx.db, { symbol: 'uL', name: 'microliter', category: 'volume' }) + const now = utcNow() + const [c] = await ctx.db + .insert(storageContainer) + .values({ specimenId: sp.id, unitId: unit.id, totalQuantity: 1, remainingQuantity: 1, created: now, lastUpdated: now }) + .returning() + if (!c) throw new Error('no container') + const collection = { name: `Coll-${collectionType}`, locationId: loc.id, created: now, lastUpdated: now } + + let id: number + if (collectionType === 'cryovial_box') { + const [row] = await ctx.db.insert(cryovialBox).values(collection).returning() + id = row!.id + await ctx.db.insert(cryovialTube).values({ id: c.id, collectionId: id, barcode: 'CRYO1', position: 'A1' }) + } else { + const table = collectionType === 'box' ? box : bag + const [row] = await ctx.db.insert(table).values(collection).returning() + id = row!.id + const [sh] = await ctx.db + .insert(sheet) + .values({ name: 'S1', boxId: collectionType === 'box' ? id : null, bagId: collectionType === 'bag' ? id : null, created: now, lastUpdated: now }) + .returning() + await ctx.db.insert(paper).values({ id: c.id, sheetId: sh!.id }) + } + + const res = await ctx.request('/api/collections/delete-with-contents', { + method: 'POST', + json: { collectionType, id, removeEmptySubjects: false }, + }) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + containersDeleted: 1, + specimensDeleted: 1, + sheetsDeleted: collectionType === 'cryovial_box' ? 0 : 1, + collectionDeleted: true, + }) + expect(await ctx.db.select().from(storageContainer).where(eq(storageContainer.id, c.id)).get()).toBeUndefined() + expect(await ctx.db.select().from(sheet).all()).toEqual([]) + }, + ) + it('deletes a micronix plate, its tubes, and fully-contained specimens', async () => { const storageType = await createTestStorageType(ctx.db, { name: 'StDel' }) const loc = await createTestLocation(ctx.db, { From 28ee5e7e757f8167482beb2581b64dac98fbf5a0 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:57:14 -0700 Subject: [PATCH 37/85] refactor(api): use one named chunk size, SQL_IN_CHUNK_SIZE, for IN lists. The export chunked specimen ids by 5000 and everything else by 500, with no reason recorded for the difference. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/export/query.ts | 4 ++-- packages/api/src/lib/sql-helpers.ts | 7 +++++++ .../api/src/lib/statistics/container-aggregates.ts | 4 ++-- packages/api/src/lib/statistics/dashboard-stats.ts | 8 ++++---- .../api/src/lib/statistics/specimen-aggregates.ts | 4 ++-- packages/api/src/lib/statistics/storage-aggregates.ts | 10 +++++----- packages/api/src/lib/studies/study-read.ts | 11 +++++------ packages/api/src/routes/specimen-types.ts | 8 +++----- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index eab90c2..2fc3613 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -21,7 +21,7 @@ import type { StudyRecord, } from './types' import { buildExportSummary, validateStudyCodes } from './validate' -import { chunkArray, dateToUpperBound } from '../sql-helpers' +import { chunkArray, dateToUpperBound, SQL_IN_CHUNK_SIZE } from '../sql-helpers' import { shiftCalendarDate } from '../datetime' export async function buildContainerQuery(database: Database, filters: ExportFilters) { @@ -154,7 +154,7 @@ export async function buildContainerQuery(database: Database, filters: ExportFil // Get matching containers, chunking specimen ids to stay under SQLite's parameter limit let containers: Array = [] - for (const idChunk of chunkArray(specimenIds, 5000)) { + for (const idChunk of chunkArray(specimenIds, SQL_IN_CHUNK_SIZE)) { containers.push( ...(await database .select() diff --git a/packages/api/src/lib/sql-helpers.ts b/packages/api/src/lib/sql-helpers.ts index b9c663f..3b8e10f 100644 --- a/packages/api/src/lib/sql-helpers.ts +++ b/packages/api/src/lib/sql-helpers.ts @@ -11,6 +11,13 @@ export function dateToUpperBound(column: SQLiteColumn, dateTo: string) { return next ? lt(column, next) : lte(column, dateTo) } +/** + * Ids per `IN (...)` list when a list is chunked. SQLite caps bound parameters per statement + * (32766 today, 999 before 3.32); 500 stays far under either cap, leaving room for the + * query's other parameters, while keeping the number of round trips low. + */ +export const SQL_IN_CHUNK_SIZE = 500 + /** Batch an array into chunks for SQLite variable limits. */ export function chunkArray(array: T[], chunkSize: number): T[][] { const chunks: T[][] = [] diff --git a/packages/api/src/lib/statistics/container-aggregates.ts b/packages/api/src/lib/statistics/container-aggregates.ts index 9639317..e51f671 100644 --- a/packages/api/src/lib/statistics/container-aggregates.ts +++ b/packages/api/src/lib/statistics/container-aggregates.ts @@ -2,7 +2,7 @@ import type { Database } from '../../db/client' import { storageContainer, storageContainerTag, tag } from '../../db/schema' import { eq, inArray } from 'drizzle-orm' import type { ContainerSubtype } from '../container-placement' -import { chunkArray } from '../sql-helpers' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' export type ContainerAggregates = { byType: Record @@ -28,7 +28,7 @@ export async function computeContainerAggregates( let containerTags: Array<{ containerId: number; tagId: number; tagName: string }> = [] if (finalContainerIds.length > 0) { - const containerIdChunks = chunkArray(finalContainerIds, 500) + const containerIdChunks = chunkArray(finalContainerIds, SQL_IN_CHUNK_SIZE) const tagChunkResults = await Promise.all( containerIdChunks.map(async (chunk) => { return database diff --git a/packages/api/src/lib/statistics/dashboard-stats.ts b/packages/api/src/lib/statistics/dashboard-stats.ts index 1e1d076..d868eb3 100644 --- a/packages/api/src/lib/statistics/dashboard-stats.ts +++ b/packages/api/src/lib/statistics/dashboard-stats.ts @@ -12,7 +12,7 @@ import { import { eq, and, sql, inArray } from 'drizzle-orm' import { resolveContainerTypes } from '../container-placement' import { buildDateFilter } from './helpers' -import { chunkArray } from '../sql-helpers' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' import { computeContainerAggregates } from './container-aggregates' import { resolveContainerIdsAtLocations, @@ -203,7 +203,7 @@ export async function getDashboardStatistics( // Batch query if too many specimen IDs to avoid SQLite variable limit // Use Promise.all to parallelize queries for better performance - const specimenChunks = chunkArray(specimenIds, 500) + const specimenChunks = chunkArray(specimenIds, SQL_IN_CHUNK_SIZE) const chunkResults = await Promise.all( specimenChunks.map(async (specimenChunk, chunkIndex) => { @@ -250,7 +250,7 @@ export async function getDashboardStatistics( } else { // Fetch containers matching location and other filters // Use Promise.all to parallelize queries - const containerIdChunks = chunkArray(locationFilteredContainerIds, 500) + const containerIdChunks = chunkArray(locationFilteredContainerIds, SQL_IN_CHUNK_SIZE) const chunkResults = await Promise.all( containerIdChunks.map(async (containerIdChunk) => { // Tag filter is already intersected into locationFilteredContainerIds. @@ -265,7 +265,7 @@ export async function getDashboardStatistics( } else { // No location filter if (tagFilteredContainerIds) { - const tagIdChunks = chunkArray(tagFilteredContainerIds, 500) + const tagIdChunks = chunkArray(tagFilteredContainerIds, SQL_IN_CHUNK_SIZE) const chunkResults = await Promise.all( tagIdChunks.map((idChunk) => database.select().from(storageContainer).where(inArray(storageContainer.id, idChunk)), diff --git a/packages/api/src/lib/statistics/specimen-aggregates.ts b/packages/api/src/lib/statistics/specimen-aggregates.ts index e03dd5c..d124cb6 100644 --- a/packages/api/src/lib/statistics/specimen-aggregates.ts +++ b/packages/api/src/lib/statistics/specimen-aggregates.ts @@ -2,7 +2,7 @@ import type { Database } from '../../db/client' import { specimen, specimenType, study, studySubject } from '../../db/schema' import { inArray } from 'drizzle-orm' import { cache, cacheKeys } from '../cache' -import { chunkArray } from '../sql-helpers' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' export type SpecimenAggregates = { total: number @@ -92,7 +92,7 @@ export async function computeSpecimenAggregates( const uniqueSubjectIds = [...new Set(subjectSpecimens.map((s) => s.studySubjectId!))] if (uniqueSubjectIds.length > 0) { - const subjectChunks = chunkArray(uniqueSubjectIds, 500) + const subjectChunks = chunkArray(uniqueSubjectIds, SQL_IN_CHUNK_SIZE) const allSubjects: Array<{ id: number; studyId: number }> = [] for (const chunk of subjectChunks) { diff --git a/packages/api/src/lib/statistics/storage-aggregates.ts b/packages/api/src/lib/statistics/storage-aggregates.ts index 8924604..0e9f5df 100644 --- a/packages/api/src/lib/statistics/storage-aggregates.ts +++ b/packages/api/src/lib/statistics/storage-aggregates.ts @@ -2,7 +2,7 @@ import type { Database } from '../../db/client' import { location } from '../../db/schema' import { inArray } from 'drizzle-orm' import { resolveContainerPlacements } from '../container-placement' -import { chunkArray } from '../sql-helpers' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' export type StorageStatistics = { byLocation: Array<{ location: string; count: number }> @@ -22,7 +22,7 @@ async function loadLocationMap(database: Database, locationIds: number[]) { return { byLocation, byRootLocation } } - const locationChunks = chunkArray(locationIds, 500) + const locationChunks = chunkArray(locationIds, SQL_IN_CHUNK_SIZE) const locationChunkResults = await Promise.all( locationChunks.map(async (chunk) => { return database.select().from(location).where(inArray(location.id, chunk)) @@ -45,7 +45,7 @@ async function loadLocationMap(database: Database, locationIds: number[]) { }) if (parentIdsToLoad.size > 0) { - const parentChunks = chunkArray(Array.from(parentIdsToLoad), 500) + const parentChunks = chunkArray(Array.from(parentIdsToLoad), SQL_IN_CHUNK_SIZE) for (const chunk of parentChunks) { const parentLocations = await database .select() @@ -65,7 +65,7 @@ async function loadLocationMap(database: Database, locationIds: number[]) { }) while (additionalParents.size > 0) { - const parentChunks = chunkArray(Array.from(additionalParents), 500) + const parentChunks = chunkArray(Array.from(additionalParents), SQL_IN_CHUNK_SIZE) additionalParents = new Set() for (const chunk of parentChunks) { const parentLocations = await database @@ -134,7 +134,7 @@ export async function computeStorageStatistics( const locationIds: number[] = [] if (finalContainerIds.length > 0) { - for (const chunk of chunkArray(finalContainerIds, 500)) { + for (const chunk of chunkArray(finalContainerIds, SQL_IN_CHUNK_SIZE)) { const placementMap = await resolveContainerPlacements(database, chunk) for (const containerId of chunk) { const placement = placementMap.get(containerId) diff --git a/packages/api/src/lib/studies/study-read.ts b/packages/api/src/lib/studies/study-read.ts index 0bb66c8..c825a80 100644 --- a/packages/api/src/lib/studies/study-read.ts +++ b/packages/api/src/lib/studies/study-read.ts @@ -2,6 +2,7 @@ import type { Database } from '../../db/client' import type { Database as SQLiteDatabase } from 'bun:sqlite' import { study, studySubject } from '../../db/schema' import { eq, inArray, sql } from 'drizzle-orm' +import { SQL_IN_CHUNK_SIZE } from '../sql-helpers' export type ListStudiesParams = { search?: string @@ -31,8 +32,6 @@ export type GetStudySummariesResult = { summaries: StudySummary[] } -const SQLITE_MAX_VARS = 500 - /** List studies with optional search and pagination. */ export async function listStudies(database: Database, params: ListStudiesParams): Promise { const { search, page, limit } = params @@ -111,8 +110,8 @@ export async function getStudySummaries( collectionDate: string | null }> = [] - for (let i = 0; i < allSubjectIds.length; i += SQLITE_MAX_VARS) { - const batch = allSubjectIds.slice(i, i + SQLITE_MAX_VARS) + for (let i = 0; i < allSubjectIds.length; i += SQL_IN_CHUNK_SIZE) { + const batch = allSubjectIds.slice(i, i + SQL_IN_CHUNK_SIZE) const placeholders = batch.map(() => '?').join(',') const specimensQuery = ` SELECT @@ -158,8 +157,8 @@ export async function getStudySummaries( } }) - for (let i = 0; i < specimenIds.length; i += SQLITE_MAX_VARS) { - const batch = specimenIds.slice(i, i + SQLITE_MAX_VARS) + for (let i = 0; i < specimenIds.length; i += SQL_IN_CHUNK_SIZE) { + const batch = specimenIds.slice(i, i + SQL_IN_CHUNK_SIZE) const containerPlaceholders = batch.map(() => '?').join(',') const containersQuery = ` SELECT specimen_id, COUNT(*) as count diff --git a/packages/api/src/routes/specimen-types.ts b/packages/api/src/routes/specimen-types.ts index ca60b73..d49d55f 100644 --- a/packages/api/src/routes/specimen-types.ts +++ b/packages/api/src/routes/specimen-types.ts @@ -17,7 +17,7 @@ import { parseId, requireParam } from '../lib/common-validators' import { handleRouteError, NotFoundError } from '../lib/error-handler' import { createAdminMiddleware, createAuthMiddleware } from '../middleware/auth' import { utcNow } from '../lib/datetime' -import { chunkArray } from '../lib/sql-helpers' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../lib/sql-helpers' const createSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -77,10 +77,8 @@ async function checkContainerTypeInUse( const containerIds = containers.map((c: any) => c.id) - // SQLite has a limit on the number of variables in a query (typically 999) - // Batch the queries to avoid exceeding this limit - const SQLITE_MAX_VARS = 500 // Conservative limit - const chunks = chunkArray(containerIds, SQLITE_MAX_VARS) + // Batch the queries to stay under SQLite's parameter limit + const chunks = chunkArray(containerIds, SQL_IN_CHUNK_SIZE) // Check if any of these containers exist in the specific container type table let foundContainer: any = null From 753fcd415c67fd9946be17d334e0e7d782425f72 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:58:16 -0700 Subject: [PATCH 38/85] fix(api): reply 409 when a container write hits a constraint. createContainerForSpecimen turned registration's 409 into { success: false }, so its callers reported a constraint hit as their own failure: add-container and POST /specimens replied 400, and derivations, control batches and /specimens/bulk threw a plain Error (500). It now throws ConflictError with the errors, and POST /specimens lets it through instead of wrapping it in a ValidationError. Each route has a test that makes the insert fail as a concurrent write would and expects 409. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integration/control-batch.test.ts | 68 +++++++++++++++++++ .../__tests__/integration/derivations.test.ts | 22 ++++++ packages/api/src/lib/container-creation.ts | 3 + .../src/routes/__tests__/specimens.test.ts | 67 ++++++++++++++++++ packages/api/src/routes/specimens.ts | 5 +- 5 files changed, 163 insertions(+), 2 deletions(-) diff --git a/packages/api/src/__tests__/integration/control-batch.test.ts b/packages/api/src/__tests__/integration/control-batch.test.ts index e9171c2..a41f158 100644 --- a/packages/api/src/__tests__/integration/control-batch.test.ts +++ b/packages/api/src/__tests__/integration/control-batch.test.ts @@ -6,8 +6,16 @@ import { } from '../helpers/authenticated-route-test' import { createTestControlDefinition, + createTestLocation, + createTestMicronixPlate, + createTestSpecimenType, + createTestStorageType, + createTestUnit, } from '../helpers/factories' import { createControlsRoutes } from '../../routes/controls' +import { setContainerDefaults } from '../../lib/settings' +import { containerTypeUnit, controlBatch, specimenTypeContainerType } from '../../db/schema' +import { failInsertsWithUniqueViolation } from '../helpers/constraint-race' describe('Control Batch Creation Integration Tests', () => { let ctx: AuthenticatedRouteTestContext @@ -65,4 +73,64 @@ describe('Control Batch Creation Integration Tests', () => { const data = await response.json() as { error: string } expect(data).toHaveProperty('error') }) + + describe('when a container write hits a constraint', () => { + let definitionId: number + let plateId: number + + beforeEach(async () => { + const unit = await createTestUnit(ctx.db, { symbol: 'uL', name: 'microliter', category: 'volume' }) + await setContainerDefaults(ctx.db, { + micronix_tube: { totalQuantity: 1, remainingQuantity: 1, defaultUnitSymbol: 'uL' }, + }) + await ctx.db.insert(containerTypeUnit).values({ containerType: 'micronix_tube', unitId: unit.id }) + definitionId = (await createTestControlDefinition(ctx.db, { name: 'Def1' })).id + const specimenType = await createTestSpecimenType(ctx.db, { name: 'DNA' }) + await ctx.db.insert(specimenTypeContainerType).values({ specimenTypeId: specimenType.id, containerType: 'micronix_tube' }) + const storageType = await createTestStorageType(ctx.db, { name: 'Freezer' }) + const location = await createTestLocation(ctx.db, { name: 'Loc', storageTypeId: String(storageType.id) }) + plateId = (await createTestMicronixPlate(ctx.db, { name: 'Plate1', locationId: location.id })).id + failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube') + }) + + function specimens() { + return [ + { + specimenTypeName: 'DNA', + containers: [{ containerType: 'micronix_tube', barcode: 'MT001', collection: { type: 'micronix_plate', id: plateId, position: 'A01' } }], + }, + ] + } + + it('create-with-specimens returns 409', async () => { + const res = await ctx.request('/api/blood-controls/batches/create-with-specimens', { + method: 'POST', + json: { batch: { controlDefinitionId: definitionId, name: 'Batch1' }, specimens: specimens() }, + }) + + expect(res.status).toBe(409) + expect(await ctx.db.select().from(controlBatch)).toEqual([]) + }) + + it('create-many-with-specimens returns 409', async () => { + const res = await ctx.request('/api/blood-controls/batches/create-many-with-specimens', { + method: 'POST', + json: { batches: [{ batch: { controlDefinitionId: definitionId, name: 'Batch1' }, specimens: specimens() }] }, + }) + + expect(res.status).toBe(409) + expect(await ctx.db.select().from(controlBatch)).toEqual([]) + }) + + it('adding specimens to a batch returns 409', async () => { + const [batch] = await ctx.db.insert(controlBatch).values({ controlDefinitionId: definitionId, name: 'Existing' }).returning() + + const res = await ctx.request(`/api/blood-controls/batches/${batch.id}/specimens/bulk`, { + method: 'POST', + json: { specimens: specimens() }, + }) + + expect(res.status).toBe(409) + }) + }) }) diff --git a/packages/api/src/__tests__/integration/derivations.test.ts b/packages/api/src/__tests__/integration/derivations.test.ts index b0a49f8..9774d92 100644 --- a/packages/api/src/__tests__/integration/derivations.test.ts +++ b/packages/api/src/__tests__/integration/derivations.test.ts @@ -16,6 +16,7 @@ import { setContainerDefaults } from '../../lib/settings' import { specimenTypeContainerType, containerTypeUnit, storageContainer, micronixTube } from '../../db/schema' import type { Database } from '../../db/client' import { utcNow } from '../../lib/datetime' +import { failInsertsWithUniqueViolation } from '../helpers/constraint-race' const BASE = '/api/derivations' @@ -135,6 +136,27 @@ describe('Derivation Workflow Integration Tests', () => { expect(list.derivations).toHaveLength(1) }) + it('returns 409 when the child container write hits a constraint', async () => { + const { parentContainerId, targetPlateId } = await createParentContainerFixture(ctx.db) + failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube') + + const res = await ctx.request(`${BASE}/containers/${parentContainerId}/derive`, { + method: 'POST', + json: { + derivationType: 'aliquot', + specimenTypeName: 'DNA', + container: { + containerType: 'micronix_tube', + barcode: 'MT-CHILD', + collection: { type: 'micronix_plate', id: targetPlateId, position: 'A01' }, + }, + }, + }) + + expect(res.status).toBe(409) + expect(await ctx.db.select().from(storageContainer)).toHaveLength(1) + }) + it('returns derivation source for the child container', async () => { const { parentContainerId, targetPlateId } = await createParentContainerFixture(ctx.db) diff --git a/packages/api/src/lib/container-creation.ts b/packages/api/src/lib/container-creation.ts index 8e2278c..8ffd31f 100644 --- a/packages/api/src/lib/container-creation.ts +++ b/packages/api/src/lib/container-creation.ts @@ -2,6 +2,7 @@ import type { Database } from '../db/client' import type { ContainerWriteInput } from '@sampledb/contract' import type { DatabaseOrTransaction } from './db-types' import { registerSpecimens, SpecimenRegistrationError } from './specimen-registration' +import { ConflictError } from './error-handler' export type ContainerType = 'micronix_tube' | 'cryovial_tube' | 'paper' | 'static_well' @@ -28,6 +29,7 @@ export type CreateContainerForSpecimenOptions = { /** * Add one Container to an existing Specimen through Specimen registration. * Callers not yet migrated to registerSpecimens use this; it reports the first problem. + * A write that hits a database constraint throws ConflictError, so every caller replies 409. */ export async function createContainerForSpecimen( specimenId: number, @@ -46,6 +48,7 @@ export async function createContainerForSpecimen( return { success: true, containerId: result.entries[0]?.containerIds[0] } } catch (error) { if (error instanceof SpecimenRegistrationError) { + if (error.status === 409) throw new ConflictError(error.message, { errors: error.errors }) return { success: false, error: error.message } } throw error diff --git a/packages/api/src/routes/__tests__/specimens.test.ts b/packages/api/src/routes/__tests__/specimens.test.ts index 8406b3f..bf42668 100644 --- a/packages/api/src/routes/__tests__/specimens.test.ts +++ b/packages/api/src/routes/__tests__/specimens.test.ts @@ -30,6 +30,7 @@ import { paper, } from '../../db/schema' import { eq } from 'drizzle-orm' +import { failInsertsWithUniqueViolation } from '../../__tests__/helpers/constraint-race' describe('Specimens API', () => { let ctx: AuthenticatedRouteTestContext @@ -251,6 +252,56 @@ describe('Specimens API', () => { const afterSpecimens = await ctx.db.select({ id: specimen.id }).from(specimen) expect(afterSpecimens.length).toBe(beforeSpecimens.length) }) + + it('POST /api/specimens returns 409 when the container write hits a constraint', async () => { + failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube') + + const res = await ctx.request('/api/specimens', { + method: 'POST', + json: { + sourceType: 'subject', + studyShortCode: 'BLK01', + subjectName: 'BULK-SUBJ', + specimenTypeName: 'Whole Blood', + collectionDate: '2024-06-03', + container: { + containerType: 'micronix_tube', + barcode: 'RACE-001', + collection: { type: 'micronix_plate', name: 'BULK-PLATE', position: 'C01' }, + }, + }, + }) + + expect(res.status).toBe(409) + expect(await ctx.db.select().from(specimen).where(eq(specimen.collectionDate, '2024-06-03'))).toEqual([]) + }) + + it('POST /api/specimens/bulk returns 409 when the container write hits a constraint', async () => { + failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube') + + const res = await ctx.request('/api/specimens/bulk', { + method: 'POST', + json: { + specimens: [ + { + sourceType: 'subject', + studyShortCode: 'BLK01', + subjectName: 'BULK-SUBJ', + specimenTypeName: 'Whole Blood', + collectionDate: '2024-06-04', + container: { + containerType: 'micronix_tube', + barcode: 'RACE-002', + collection: { type: 'micronix_plate', name: 'BULK-PLATE', position: 'C02' }, + }, + }, + ], + }, + }) + + expect(res.status).toBe(409) + expect(await ctx.db.select().from(specimen).where(eq(specimen.collectionDate, '2024-06-04'))).toEqual([]) + }) }) describe('POST /api/specimens/bulk/validate', () => { @@ -422,6 +473,22 @@ describe('Specimens API', () => { expect(tubes[0].position).toBe('A01') }) + it('returns 409 when the container write hits a constraint', async () => { + failInsertsWithUniqueViolation(ctx.sqlite, 'micronix_tube') + + const res = await ctx.request(`/api/specimens/${testSpecimen.id}/containers`, { + method: 'POST', + json: { + containerType: 'micronix_tube', + barcode: 'RACE-TUBE-001', + collection: { type: 'micronix_plate', name: 'ADD-PLATE', position: 'A01' }, + }, + }) + + expect(res.status).toBe(409) + expect(await ctx.db.select().from(storageContainer).where(eq(storageContainer.specimenId, testSpecimen.id))).toEqual([]) + }) + it('accepts unitId and quantities from container defaults (web payload)', async () => { const [uL] = await ctx.db.select().from(unit).where(eq(unit.symbol, 'uL')) const res = await ctx.request(`/api/specimens/${testSpecimen.id}/containers`, { diff --git a/packages/api/src/routes/specimens.ts b/packages/api/src/routes/specimens.ts index 808b456..f5a8744 100644 --- a/packages/api/src/routes/specimens.ts +++ b/packages/api/src/routes/specimens.ts @@ -10,7 +10,7 @@ import { toContainerWriteInput, type BulkCombinedContainerInput, } from '../lib/container-write-placement' -import { handleRouteError, NotFoundError, ValidationError } from '../lib/error-handler' +import { ConflictError, handleRouteError, NotFoundError, ValidationError } from '../lib/error-handler' import { containerSchema, containerSchemaWithLocation } from '../lib/schemas' import { createAuthMiddleware, createMemberMiddleware } from '../middleware/auth' import { utcNow } from '../lib/datetime' @@ -206,7 +206,8 @@ specimens.post('/', memberMiddleware, async (c) => { } catch (err) { await dbInstance.delete(storageContainer).where(eq(storageContainer.specimenId, newSpecimen.id)) await dbInstance.delete(specimen).where(eq(specimen.id, newSpecimen.id)) - throw err instanceof ValidationError ? err : new ValidationError(err instanceof Error ? err.message : 'Failed to create container') + if (err instanceof ValidationError || err instanceof ConflictError) throw err + throw new ValidationError(err instanceof Error ? err.message : 'Failed to create container') } } From 97b03270fb920060f1ec6b653ebe3c7b9eae9bcd Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:58:27 -0700 Subject: [PATCH 39/85] refactor(web): list the command dependencies once and rebuild commands only when one changes. useCommands now keeps the caller's deps object while its fields are shallow-equal, and commands take the route as pathname and search, the only parts of the location they read. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/App.tsx | 3 +- .../hooks/__tests__/useShallowStable.test.ts | 29 ++++++++++ packages/web/src/hooks/useShallowStable.ts | 18 +++++++ .../__tests__/registry.test.ts | 3 +- .../__tests__/useCommands.test.tsx | 45 ++++++++++++++++ .../src/lib/command-registry/command-deps.ts | 7 ++- .../lib/command-registry/groups/contextual.ts | 6 +-- .../src/lib/command-registry/groups/create.ts | 2 +- .../web/src/lib/command-registry/registry.ts | 54 ++----------------- 9 files changed, 110 insertions(+), 57 deletions(-) create mode 100644 packages/web/src/hooks/__tests__/useShallowStable.test.ts create mode 100644 packages/web/src/hooks/useShallowStable.ts create mode 100644 packages/web/src/lib/command-registry/__tests__/useCommands.test.tsx diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index f042bee..77c37bc 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -192,7 +192,8 @@ function AppContent() { const commands = useCommands({ navigate, - location, + pathname: location.pathname, + search: location.search, canWrite, isAdmin, canManageReferenceData, diff --git a/packages/web/src/hooks/__tests__/useShallowStable.test.ts b/packages/web/src/hooks/__tests__/useShallowStable.test.ts new file mode 100644 index 0000000..a3c1db7 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useShallowStable.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useShallowStable } from '../useShallowStable' + +describe('useShallowStable', () => { + it('keeps the first object while a fresh one has the same fields', () => { + const fn = () => {} + const { result, rerender } = renderHook(({ value }) => useShallowStable(value), { + initialProps: { value: { a: 1, fn } }, + }) + const first = result.current + rerender({ value: { a: 1, fn } }) + expect(result.current).toBe(first) + }) + + it('returns the new object when a field changes', () => { + const { result, rerender } = renderHook(({ value }) => useShallowStable(value), { + initialProps: { value: { a: 1 } as Record }, + }) + const changed = { a: 2 } + rerender({ value: changed }) + expect(result.current).toBe(changed) + rerender({ value: { a: 2 } }) + expect(result.current).toBe(changed) + const added = { a: 2, b: 3 } + rerender({ value: added }) + expect(result.current).toBe(added) + }) +}) diff --git a/packages/web/src/hooks/useShallowStable.ts b/packages/web/src/hooks/useShallowStable.ts new file mode 100644 index 0000000..faaecba --- /dev/null +++ b/packages/web/src/hooks/useShallowStable.ts @@ -0,0 +1,18 @@ +import { useState } from 'react' + +function shallowEqual(a: T, b: T): boolean { + const aKeys = Object.keys(a) as (keyof T)[] + return aKeys.length === Object.keys(b).length && aKeys.every((key) => Object.is(a[key], b[key])) +} + +/** + * Returns the previous object while every property is unchanged (`Object.is`), so a caller that + * builds an object each render can memoize on it without listing its fields. + */ +export function useShallowStable(value: T): T { + const [stable, setStable] = useState(value) + if (stable === value || shallowEqual(stable, value)) return stable + // Storing information from previous renders: https://react.dev/reference/react/useState#storing-information-from-previous-renders + setStable(value) + return value +} diff --git a/packages/web/src/lib/command-registry/__tests__/registry.test.ts b/packages/web/src/lib/command-registry/__tests__/registry.test.ts index e7b509d..052f7b5 100644 --- a/packages/web/src/lib/command-registry/__tests__/registry.test.ts +++ b/packages/web/src/lib/command-registry/__tests__/registry.test.ts @@ -5,7 +5,8 @@ import type { CommandDependencies } from '../command-deps' function mockDeps(overrides: Partial = {}): CommandDependencies { return { navigate: vi.fn() as CommandDependencies['navigate'], - location: { pathname: '/', search: '', hash: '', state: null, key: 'default' }, + pathname: '/', + search: '', canWrite: true, isAdmin: false, canManageReferenceData: false, diff --git a/packages/web/src/lib/command-registry/__tests__/useCommands.test.tsx b/packages/web/src/lib/command-registry/__tests__/useCommands.test.tsx new file mode 100644 index 0000000..1e2c881 --- /dev/null +++ b/packages/web/src/lib/command-registry/__tests__/useCommands.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useCommands } from '../registry' +import type { CommandDependencies } from '../command-deps' + +const stable = { + navigate: vi.fn() as CommandDependencies['navigate'], + setTheme: vi.fn(), + toggleHelpModal: vi.fn(), + openSearchModal: vi.fn(), + refreshUser: vi.fn(async () => {}), + handleExportSpecimens: vi.fn(), + handleExportInventory: vi.fn(), + handleClearFilters: vi.fn(), +} + +const deps = (overrides: Partial = {}): CommandDependencies => ({ + ...stable, + pathname: '/', + search: '', + canWrite: true, + isAdmin: false, + canManageReferenceData: false, + theme: 'light', + ...overrides, +}) + +describe('useCommands', () => { + it('keeps the same commands when the caller passes a fresh but equal deps object', () => { + const { result, rerender } = renderHook(({ d }) => useCommands(d), { initialProps: { d: deps() } }) + const first = result.current + rerender({ d: deps() }) + expect(result.current).toBe(first) + }) + + it('rebuilds when the route or a permission changes', () => { + const { result, rerender } = renderHook(({ d }) => useCommands(d), { initialProps: { d: deps() } }) + const first = result.current + rerender({ d: deps({ search: '?q=1' }) }) + const second = result.current + expect(second).not.toBe(first) + rerender({ d: deps({ search: '?q=1', isAdmin: true }) }) + expect(result.current).not.toBe(second) + }) +}) diff --git a/packages/web/src/lib/command-registry/command-deps.ts b/packages/web/src/lib/command-registry/command-deps.ts index 0caf006..a6082b8 100644 --- a/packages/web/src/lib/command-registry/command-deps.ts +++ b/packages/web/src/lib/command-registry/command-deps.ts @@ -1,12 +1,15 @@ -import type { NavigateFunction, Location } from 'react-router-dom' +import type { NavigateFunction } from 'react-router-dom' import type { Theme } from '../../contexts/ThemeContext' /** * Dependencies injected from the app shell for building command lists. + * Keep every field a primitive or a stable function: useCommands rebuilds when any field changes. */ export interface CommandDependencies { navigate: NavigateFunction - location: Location + /** Current route; the only parts of the location commands read. */ + pathname: string + search: string canWrite: boolean isAdmin: boolean canManageReferenceData: boolean diff --git a/packages/web/src/lib/command-registry/groups/contextual.ts b/packages/web/src/lib/command-registry/groups/contextual.ts index 610c8d6..d27068f 100644 --- a/packages/web/src/lib/command-registry/groups/contextual.ts +++ b/packages/web/src/lib/command-registry/groups/contextual.ts @@ -2,14 +2,14 @@ import type { Command } from '../../commands' import type { CommandDependencies } from '../command-deps' function mergeSearch(d: CommandDependencies, mutator: (sp: URLSearchParams) => void): void { - const sp = new URLSearchParams(d.location.search) + const sp = new URLSearchParams(d.search) mutator(sp) const next = sp.toString() - d.navigate({ pathname: d.location.pathname, search: next ? `?${next}` : '' }) + d.navigate({ pathname: d.pathname, search: next ? `?${next}` : '' }) } export function buildContextualCommands(d: CommandDependencies): Command[] { - const p = d.location.pathname + const p = d.pathname const out: Command[] = [] const studyMatch = p.match(/^\/studies\/(\d+)$/) diff --git a/packages/web/src/lib/command-registry/groups/create.ts b/packages/web/src/lib/command-registry/groups/create.ts index 872ff74..ec0e2ee 100644 --- a/packages/web/src/lib/command-registry/groups/create.ts +++ b/packages/web/src/lib/command-registry/groups/create.ts @@ -12,7 +12,7 @@ function subjectDetailId(pathname: string): string | null { } export function buildCreateCommands(d: CommandDependencies): Command[] { - const { pathname, search } = d.location + const { pathname, search } = d const out: Command[] = [] if (pathname === '/' || pathname === '/studies' || /^\/studies\/\d+$/.test(pathname)) { diff --git a/packages/web/src/lib/command-registry/registry.ts b/packages/web/src/lib/command-registry/registry.ts index a6f7883..545de8d 100644 --- a/packages/web/src/lib/command-registry/registry.ts +++ b/packages/web/src/lib/command-registry/registry.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { useShallowStable } from '../../hooks/useShallowStable' import type { Command } from '../commands' import type { CommandDependencies } from './command-deps' import { buildNavigationCommands } from './groups/navigation' @@ -21,53 +22,8 @@ export function buildAllCommands(deps: CommandDependencies): Command[] { ] } -export function useCommands({ - navigate, - location, - canWrite, - isAdmin, - canManageReferenceData, - theme, - setTheme, - toggleHelpModal, - openSearchModal, - refreshUser, - handleExportSpecimens, - handleExportInventory, - handleClearFilters, -}: CommandDependencies) { - // The caller builds a new deps object each render, so memoize on its fields. - return useMemo( - () => - buildAllCommands({ - navigate, - location, - canWrite, - isAdmin, - canManageReferenceData, - theme, - setTheme, - toggleHelpModal, - openSearchModal, - refreshUser, - handleExportSpecimens, - handleExportInventory, - handleClearFilters, - }), - [ - navigate, - location, - canWrite, - isAdmin, - canManageReferenceData, - theme, - setTheme, - toggleHelpModal, - openSearchModal, - refreshUser, - handleExportSpecimens, - handleExportInventory, - handleClearFilters, - ] - ) +export function useCommands(deps: CommandDependencies): Command[] { + // The caller builds a new deps object each render; keep the last one while its fields are unchanged. + const stableDeps = useShallowStable(deps) + return useMemo(() => buildAllCommands(stableDeps), [stableDeps]) } From 40f483fe3089e1e8842486822c93211e92e9c8b8 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:59:02 -0700 Subject: [PATCH 40/85] fix(registration): blame a collection insert conflict on the row that asked for it. writeRegistration reported a constraint hit while inserting a planned Collection against entry 0, because its current-row marker only moved once specimens were written. Each planned Collection now records the first container that needs it (directly, or as its Sheet's box or bag), and a 409 on that insert names that entry and container. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/specimen-registration.test.ts | 23 ++++++++++++++++ .../src/lib/specimen-registration/write.ts | 27 ++++++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts index 6a4bb0f..b8851fd 100644 --- a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts +++ b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts @@ -306,4 +306,27 @@ describe('Specimen registration', () => { expect(error).toBeInstanceOf(SpecimenRegistrationError) expect(error).toMatchObject({ status: 409, errors: [{ entryIndex: 0, containerIndex: 0 }] }) }) + + it('blames a constraint hit on a new collection on the row that asked for it', async () => { + // Rows 0 and 1 use the existing plate; row 2 creates P2. Another write creates P2 first. + const entries = [ + entry([tube('T1', { name: 'P1', position: 'A01' })]), + entry([paperOn('Sheet-1'), tube('T2', { name: 'P2', locationId, position: 'A01' })], '2024-02-01'), + ] + const { plan, errors } = await planRegistration(db, entries) + expect(errors).toEqual([]) + await createTestMicronixPlate(db, { name: 'P2', barcode: 'P2-BC', locationId }) + sqlite.exec('CREATE UNIQUE INDEX test_plate_name ON micronix_plate(name)') + + const error = await writeRegistration(db, plan, undefined).then( + () => null, + (e: unknown) => e, + ) + + expect(error).toBeInstanceOf(SpecimenRegistrationError) + expect(error).toMatchObject({ status: 409 }) + expect((error as SpecimenRegistrationError).errors).toEqual([ + { entryIndex: 1, containerIndex: 1, message: 'A record with this name already exists.' }, + ]) + }) }) diff --git a/packages/api/src/lib/specimen-registration/write.ts b/packages/api/src/lib/specimen-registration/write.ts index faa75c0..9d1ff45 100644 --- a/packages/api/src/lib/specimen-registration/write.ts +++ b/packages/api/src/lib/specimen-registration/write.ts @@ -16,7 +16,7 @@ import { } from '../../db/schema' import { utcNow } from '../datetime' import { sqliteConstraintConflict } from '../error-handler' -import type { CollectionTarget } from './collections' +import type { CollectionTarget, PlannedCollection } from './collections' import type { RegistrationPlan } from './plan' import { SpecimenRegistrationError, @@ -31,6 +31,25 @@ function only(rows: T[], what: string): T { return row } +type ContainerRef = { entryIndex: number; containerIndex?: number } + +/** The first container that needs each planned Collection: directly, or as its Sheet's box or bag. */ +function collectionRequesters(plan: RegistrationPlan): ContainerRef[] { + const requesters: Array = plan.collections.map(() => undefined) + for (const { entryIndex, containerIndex, collection } of plan.containers) { + let target: CollectionTarget | null = collection + while (target && 'plannedIndex' in target) { + requesters[target.plannedIndex] ??= { entryIndex, containerIndex } + const planned: PlannedCollection = plan.collections[target.plannedIndex] + target = planned.type === 'sheet' ? planned.parent : null + } + } + return requesters.map((requester) => { + if (!requester) throw new Error('Planned collection is not used by any container') + return requester + }) +} + export async function writeRegistration( database: Database, plan: RegistrationPlan, @@ -38,7 +57,8 @@ export async function writeRegistration( ): Promise { const now = utcNow() const audit = { created: now, lastUpdated: now, createdBy: userId, updatedBy: userId } - let current: { entryIndex: number; containerIndex?: number } = { entryIndex: 0 } + const requesters = collectionRequesters(plan) + let current: ContainerRef = { entryIndex: 0 } try { const collectionIds: number[] = [] @@ -46,7 +66,8 @@ export async function writeRegistration( const idOf = (target: CollectionTarget): number => 'existingId' in target ? target.existingId : collectionIds[target.plannedIndex] - for (const planned of plan.collections) { + for (const [plannedIndex, planned] of plan.collections.entries()) { + current = requesters[plannedIndex] let id: number switch (planned.type) { case 'micronix_plate': From 9b5aef1ad8a8ebfd8de2df27d620d7cde9fc7633 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:59:06 -0700 Subject: [PATCH 41/85] refactor(web): load the user once per UserProvider with a ref, not module state. The module-level cache existed so Strict Mode's second effect run would not fetch /auth/me again. Strict Mode keeps refs across that run, so a ref does the same job, and tests no longer need resetUserContextForTesting. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/contexts/UserContext.tsx | 38 +++---------------- .../contexts/__tests__/UserContext.test.tsx | 26 +++++++++++-- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/packages/web/src/contexts/UserContext.tsx b/packages/web/src/contexts/UserContext.tsx index fc31821..69d3576 100644 --- a/packages/web/src/contexts/UserContext.tsx +++ b/packages/web/src/contexts/UserContext.tsx @@ -5,20 +5,6 @@ import { setSessionExpiredHandler } from '../lib/api/client' import { addRecentUser } from '../lib/localUserHistory' import { UserContext } from './user-context-instance' -// Run refresh once per app load; cache result for remounts (e.g. Strict Mode) -let userDidInit = false -let userCachedUser: User | null = null -let userCachedLoading = true -let userCachedError: string | null = null - -/** For tests: forget the once-per-app-load user cache so each test starts signed out. */ -export function resetUserContextForTesting(): void { - userDidInit = false - userCachedUser = null - userCachedLoading = true - userCachedError = null -} - export function UserProvider({ children }: { children: ReactNode }) { const [user, setUserState] = useState(null) const [loading, setLoading] = useState(true) @@ -26,6 +12,8 @@ export function UserProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient() const currentUserId = useRef(null) + // Strict Mode runs mount effects twice on the same instance; refs survive, so this loads the user once. + const didInit = useRef(false) const setUser = useCallback((u: User | null) => { // Cached queries include per-user data (personal export configs, settings); never show @@ -36,9 +24,6 @@ export function UserProvider({ children }: { children: ReactNode }) { } currentUserId.current = nextId setUserState(u) - userCachedUser = u - userCachedLoading = false - userCachedError = null }, [queryClient]) // A 401 from a data request means the session is gone: sign out so AuthGuard redirects. @@ -50,8 +35,6 @@ export function UserProvider({ children }: { children: ReactNode }) { const refreshUser = useCallback(async () => { setLoading(true) setError(null) - userCachedLoading = true - userCachedError = null try { const response = await authApi.getCurrentUser() const userData = response.user @@ -67,17 +50,13 @@ export function UserProvider({ children }: { children: ReactNode }) { const errMsg = axiosErr.response?.data?.error || 'Failed to load user' setError(errMsg) setUser(null) - userCachedError = errMsg } } else { - const errMsg = 'Failed to load user' - setError(errMsg) + setError('Failed to load user') setUser(null) - userCachedError = errMsg } } finally { setLoading(false) - userCachedLoading = false } }, [setUser]) @@ -130,14 +109,9 @@ export function UserProvider({ children }: { children: ReactNode }) { }, [setUser]) useEffect(() => { - if (!userDidInit) { - userDidInit = true - void refreshUser() - } else { - setUser(userCachedUser) - setLoading(userCachedLoading) - setError(userCachedError) - } + if (didInit.current) return + didInit.current = true + void refreshUser() }, [refreshUser]) // Permission helpers diff --git a/packages/web/src/contexts/__tests__/UserContext.test.tsx b/packages/web/src/contexts/__tests__/UserContext.test.tsx index 8e944ed..b4e9823 100644 --- a/packages/web/src/contexts/__tests__/UserContext.test.tsx +++ b/packages/web/src/contexts/__tests__/UserContext.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { renderHook, act, waitFor } from '@testing-library/react' +import { render, renderHook, act, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import type { ReactNode } from 'react' -import { UserProvider, useUser, resetUserContextForTesting } from '../UserContext' +import { StrictMode, type ReactNode } from 'react' +import { UserProvider, useUser } from '../UserContext' import { authApi } from '../../lib/api/auth' import { axiosApi } from '../../lib/api/client' @@ -13,7 +13,7 @@ describe('UserProvider', () => { let queryClient: QueryClient beforeEach(() => { - resetUserContextForTesting() + vi.clearAllMocks() vi.mocked(authApi.getCurrentUser).mockResolvedValue({ user: alice } as never) queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) }) @@ -27,6 +27,24 @@ describe('UserProvider', () => { return renderHook(() => useUser(), { wrapper }) } + it('loads the current user once under Strict Mode', async () => { + function SignedInAs() { + return

{useUser().user?.name ?? 'nobody'}

+ } + render( + + + + + + + , + ) + + expect(await screen.findByText('Alice')).toBeInTheDocument() + expect(authApi.getCurrentUser).toHaveBeenCalledTimes(1) + }) + it("clears cached queries when a different user signs in, so the next user never sees the last one's data", async () => { const { result } = renderUser() await waitFor(() => expect(result.current.user?.id).toBe(alice.id)) From e2c8ef89e5fdbc77454f122fdba8924293035b53 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:59:26 -0700 Subject: [PATCH 42/85] fix(registration): name the identifier that found a plate with another name. A row whose collection id found a plate named differently from the row's name was told the plate "with this barcode" had that name. The message now names the identifier that found it: the id, or the barcode. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/specimen-registration.test.ts | 12 ++++++++++++ .../api/src/lib/specimen-registration/collections.ts | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts index b8851fd..feeb6a0 100644 --- a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts +++ b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts @@ -184,6 +184,18 @@ describe('Specimen registration', () => { expect(error.errors[0].message).toContain("point to different micronix plates: 'P1' and 'P2'") }) + it('names the id when the plate it finds has another name', async () => { + const error = await rejection([entry([tube('T1', { id: plate.id, name: 'OTHER', position: 'A01' })])]) + + expect(error.errors[0].message).toBe(`Micronix plate id ${plate.id} is named 'P1', not 'OTHER'.`) + }) + + it('names the barcode when the plate it finds has another name', async () => { + const error = await rejection([entry([tube('T1', { barcode: 'P1-BC', name: 'OTHER', position: 'A01' })])]) + + expect(error.errors[0].message).toBe("Micronix plate with barcode 'P1-BC' is named 'P1', not 'OTHER'.") + }) + it('treats a plate named by name and by barcode as one plate for position clashes', async () => { const error = await rejection([ entry([tube('T1', { name: 'P1', position: 'A01' })]), diff --git a/packages/api/src/lib/specimen-registration/collections.ts b/packages/api/src/lib/specimen-registration/collections.ts index 718d19d..9c30c10 100644 --- a/packages/api/src/lib/specimen-registration/collections.ts +++ b/packages/api/src/lib/specimen-registration/collections.ts @@ -145,7 +145,9 @@ export class CollectionPlanner { return { error: `${label} '${match.name}' ${actual}, not '${collection.barcode}'.` } } if (collection.name && match.name !== collection.name) { - return { error: `${label} with this barcode is named '${match.name}', not '${collection.name}'.` } + // The name matched nothing, so the id or, failing that, the barcode found this one. + const found = collection.id != null ? `${label} id ${collection.id}` : `${label} with barcode '${collection.barcode ?? ''}'` + return { error: `${found} is named '${match.name}', not '${collection.name}'.` } } const conflict = this.plannedLocationConflict(match.target, collection.locationId) if (conflict) return { error: conflict } From 6c761b70ec8cbb10f2e93336e66afd751d03c2fe Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:59:27 -0700 Subject: [PATCH 43/85] refactor(web): fall back to one shared empty list instead of memoizing `data ?? []` per page. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/lib/empty-list.ts | 5 +++++ .../web/src/pages/AdminDataIntegrityEmptyCollections.tsx | 3 ++- packages/web/src/pages/AdminUsers.tsx | 3 ++- packages/web/src/pages/BloodControls.tsx | 5 +++-- packages/web/src/pages/Collections.tsx | 3 ++- packages/web/src/pages/ContainerMoveMicronix.tsx | 8 +++----- packages/web/src/pages/LocationDetail.tsx | 6 ++---- packages/web/src/pages/QpcrExperimentDetail.tsx | 5 +++-- packages/web/src/pages/SheetDetail.tsx | 3 ++- 9 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 packages/web/src/lib/empty-list.ts diff --git a/packages/web/src/lib/empty-list.ts b/packages/web/src/lib/empty-list.ts new file mode 100644 index 0000000..386f24a --- /dev/null +++ b/packages/web/src/lib/empty-list.ts @@ -0,0 +1,5 @@ +/** + * One shared, frozen empty array. `query.data ?? EMPTY_LIST` keeps the same identity on every + * render while data loads, so hooks that depend on the list don't rerun. + */ +export const EMPTY_LIST: never[] = Object.freeze([]) as never[] diff --git a/packages/web/src/pages/AdminDataIntegrityEmptyCollections.tsx b/packages/web/src/pages/AdminDataIntegrityEmptyCollections.tsx index db3d279..6e70e9a 100644 --- a/packages/web/src/pages/AdminDataIntegrityEmptyCollections.tsx +++ b/packages/web/src/pages/AdminDataIntegrityEmptyCollections.tsx @@ -6,6 +6,7 @@ import type { EmptyCollectionItem, EmptyCollectionsDeleteIds } from '../lib/api/ import { adminKeys, useAdminIntegrityReport } from '../hooks/useAdmin' import { Modal, PageError, fromQuery, getQueryErrorMessage } from '../ui' import '../styles/admin.css' +import { EMPTY_LIST } from '../lib/empty-list' const COLLECTION_TYPES: EmptyCollectionItem['type'][] = ['micronix_plate', 'cryovial_box', 'box', 'bag'] @@ -49,7 +50,7 @@ export default function AdminDataIntegrityEmptyCollections() { const queryClient = useQueryClient() const reportQuery = useAdminIntegrityReport() const reportStatus = fromQuery(reportQuery) - const collections = useMemo(() => reportQuery.data?.emptyCollections ?? [], [reportQuery.data]) + const collections = reportQuery.data?.emptyCollections ?? EMPTY_LIST const [mutationError, setMutationError] = useState(null) const [selected, setSelected] = useState>(new Set()) const [showDeleteModal, setShowDeleteModal] = useState(false) diff --git a/packages/web/src/pages/AdminUsers.tsx b/packages/web/src/pages/AdminUsers.tsx index ada126b..47282c0 100644 --- a/packages/web/src/pages/AdminUsers.tsx +++ b/packages/web/src/pages/AdminUsers.tsx @@ -6,6 +6,7 @@ import { adminKeys, useAdminUsers } from '../hooks/useAdmin' import { useFocusSearchOnSlash } from '../hooks/useHotkey' import { AsyncPresentation, Modal, PageError, fromQuery, getQueryErrorMessage } from '../ui' import '../styles/admin.css' +import { EMPTY_LIST } from '../lib/empty-list' interface CreateUserData { email: string @@ -48,7 +49,7 @@ export default function AdminUsers() { const [showPassword, setShowPassword] = useState(false) const usersQuery = useAdminUsers(showDeleted) - const users = useMemo(() => usersQuery.data ?? [], [usersQuery.data]) + const users = usersQuery.data ?? EMPTY_LIST const listStatus = fromQuery(usersQuery, { isEmpty: usersQuery.isSuccess && users.length === 0, }) diff --git a/packages/web/src/pages/BloodControls.tsx b/packages/web/src/pages/BloodControls.tsx index f20d94e..4c15de9 100644 --- a/packages/web/src/pages/BloodControls.tsx +++ b/packages/web/src/pages/BloodControls.tsx @@ -12,6 +12,7 @@ import { getCompositionKey } from '../lib/composition-key' import { DetailPageSkeleton, PageError, fromQuery, getQueryErrorMessage } from '../ui' import '../styles/blood-controls.css' import { parseDisplayDate } from '../lib/date-utils' +import { EMPTY_LIST } from '../lib/empty-list' export interface CompositionGroup { id: number @@ -26,8 +27,8 @@ export default function BloodControls() { const navigate = useNavigate() const { canWrite } = useUser() const overviewQuery = useBloodControlsOverview() - const definitions = useMemo(() => overviewQuery.data?.definitions ?? [], [overviewQuery.data]) - const batches = useMemo(() => overviewQuery.data?.batches ?? [], [overviewQuery.data]) + const definitions = overviewQuery.data?.definitions ?? EMPTY_LIST + const batches = overviewQuery.data?.batches ?? EMPTY_LIST const strains = overviewQuery.data?.strains ?? [] const overviewStatus = fromQuery(overviewQuery) const [searchParams, setSearchParams] = useSearchParams() diff --git a/packages/web/src/pages/Collections.tsx b/packages/web/src/pages/Collections.tsx index 5562e19..b00ff6c 100644 --- a/packages/web/src/pages/Collections.tsx +++ b/packages/web/src/pages/Collections.tsx @@ -6,6 +6,7 @@ import { useAllCollections } from '../hooks/useCollections' import { useFocusSearchOnSlash } from '../hooks/useHotkey' import { AsyncPresentation, EmptyState, PageError, fromQuery, getQueryErrorMessage } from '../ui' import '../styles/subject-specimen.css' +import { EMPTY_LIST } from '../lib/empty-list' const PAGE_SIZE = 50 const TYPE_TABS: { value: CollectionTypeFilter; label: string }[] = [ @@ -66,7 +67,7 @@ export default function Collections() { ) const collectionsQuery = useAllCollections() - const collections = useMemo(() => collectionsQuery.data ?? [], [collectionsQuery.data]) + const collections = collectionsQuery.data ?? EMPTY_LIST const listStatus = fromQuery(collectionsQuery, { isEmpty: collectionsQuery.isSuccess && collections.length === 0, }) diff --git a/packages/web/src/pages/ContainerMoveMicronix.tsx b/packages/web/src/pages/ContainerMoveMicronix.tsx index 59b727a..e09085a 100644 --- a/packages/web/src/pages/ContainerMoveMicronix.tsx +++ b/packages/web/src/pages/ContainerMoveMicronix.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Navigate } from 'react-router-dom' import { useMicronixMoveBootstrap, moveWorkflowKeys } from '../hooks/useMoveWorkflow' import { useScanMoveWorkflow } from '../hooks/useScanMoveWorkflow' @@ -12,6 +12,7 @@ import { MICRONIX_SCAN_MOVE_COPY } from '../components/scan-move/copy' import MicronixMoveInstructions from '../components/scan-move/MicronixMoveInstructions' import MicronixScannerConfigPanel from '../components/scan-move/MicronixScannerConfigPanel' import '../styles/storage.css' +import { EMPTY_LIST } from '../lib/empty-list' export default function ContainerMoveMicronix() { const { canWrite } = useUser() @@ -20,10 +21,7 @@ export default function ContainerMoveMicronix() { const bootstrapStatus = fromQuery(bootstrapQuery) const availablePlates = (bootstrapQuery.data?.plates ?? []) as MicronixPlate[] const locations = bootstrapQuery.data?.locations ?? [] - const scannerConfigurations = useMemo( - () => bootstrapQuery.data?.scannerConfigurations ?? [], - [bootstrapQuery.data], - ) + const scannerConfigurations = bootstrapQuery.data?.scannerConfigurations ?? EMPTY_LIST const refreshCollections = useCallback(async () => { await queryClient.invalidateQueries({ queryKey: moveWorkflowKeys.micronixBootstrap() }) diff --git a/packages/web/src/pages/LocationDetail.tsx b/packages/web/src/pages/LocationDetail.tsx index b38602e..c5631f7 100644 --- a/packages/web/src/pages/LocationDetail.tsx +++ b/packages/web/src/pages/LocationDetail.tsx @@ -12,6 +12,7 @@ import Pagination from '../components/Pagination' import { DetailPageSkeleton, PageError, fromQuery, getQueryErrorMessage } from '../ui' import ContentCard from '../components/ContentCard' import '../styles/storage.css' +import { EMPTY_LIST } from '../lib/empty-list' interface LocationContents { micronixPlates?: any[] @@ -47,10 +48,7 @@ export default function LocationDetail() { const location = (detailQuery.data?.location as Location | undefined) ?? null const contents = (detailQuery.data?.contents as LocationContents | undefined) ?? null const hierarchyStats = (detailQuery.data?.hierarchyStats as LocationHierarchyStats | undefined) ?? null - const allLocations = useMemo( - () => (detailQuery.data?.allLocations as Location[] | undefined) ?? [], - [detailQuery.data], - ) + const allLocations = (detailQuery.data?.allLocations as Location[] | undefined) ?? EMPTY_LIST const pagination = detailQuery.data?.pagination ?? null const pathLabel = useMemo(() => { diff --git a/packages/web/src/pages/QpcrExperimentDetail.tsx b/packages/web/src/pages/QpcrExperimentDetail.tsx index d8a5870..467a9c6 100644 --- a/packages/web/src/pages/QpcrExperimentDetail.tsx +++ b/packages/web/src/pages/QpcrExperimentDetail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useMemo } from 'react' +import { useEffect, useState, useRef } from 'react' import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import { api } from '../lib/api/client' @@ -18,6 +18,7 @@ import { useUser } from '../contexts/UserContext' import { useToast } from '../contexts/ToastContext' import '../styles/qpcr.css' import { bytesToBase64 } from '../lib/base64' +import { EMPTY_LIST } from '../lib/empty-list' const PLATE_ROWS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] as const const PLATE_COLS = 12 @@ -137,7 +138,7 @@ export default function QpcrExperimentDetail() { const experimentQuery = useQpcrExperiment(experimentId) const experimentStatus = fromQuery(experimentQuery) const scannerConfigsQuery = useQpcrScannerConfigurations() - const scannerConfigs = useMemo(() => scannerConfigsQuery.data ?? [], [scannerConfigsQuery.data]) + const scannerConfigs = scannerConfigsQuery.data ?? EMPTY_LIST const [data, setData] = useState(null) const [deleting, setDeleting] = useState(false) const [selectedConfigId, setSelectedConfigId] = useState(null) diff --git a/packages/web/src/pages/SheetDetail.tsx b/packages/web/src/pages/SheetDetail.tsx index 0d326dd..94b8cfb 100644 --- a/packages/web/src/pages/SheetDetail.tsx +++ b/packages/web/src/pages/SheetDetail.tsx @@ -14,6 +14,7 @@ import { import { useTableViewConfigurations } from '../hooks/useTableViewConfigurations' import { Link } from 'react-router-dom' import '../styles/storage.css' +import { EMPTY_LIST } from '../lib/empty-list' export default function SheetDetail() { const { id } = useParams<{ id: string }>() @@ -50,7 +51,7 @@ export default function SheetDetail() { // Derive once so hooks below can run unconditionally (Rules of Hooks) const sheet = data?.sheet ?? null - const papers = useMemo(() => (data?.papers ?? []) as CollectionTableEntry[], [data]) + const papers = (data?.papers ?? EMPTY_LIST) as CollectionTableEntry[] const tableRows = useMemo(() => { const context = sheet From 7b5dc7dc996f5fbb9d480fbf98ca40b759d89cd7 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 16:59:49 -0700 Subject: [PATCH 44/85] fix(web): sign out on a 401 from /auth user and session admin requests. Every /auth/* URL was treated as an auth endpoint, so a 401 from /auth/users, /auth/users/:id/sessions or /auth/sessions/:id never redirected to login. Only login, self-register, logout, switch, the session checks, and the password change report their own 401s. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/lib/api/__tests__/client.test.ts | 16 ++++++++++++ packages/web/src/lib/api/client.ts | 26 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/web/src/lib/api/__tests__/client.test.ts b/packages/web/src/lib/api/__tests__/client.test.ts index e3878ce..bf9a47d 100644 --- a/packages/web/src/lib/api/__tests__/client.test.ts +++ b/packages/web/src/lib/api/__tests__/client.test.ts @@ -126,6 +126,22 @@ describe('session expiry', () => { await expect(axiosApi.post('/auth/login', {})).rejects.toThrow() await expect(axiosApi.get('/auth/me')).rejects.toThrow() + await expect(axiosApi.get('/auth/current')).rejects.toThrow() + await expect(axiosApi.post('/auth/switch', {})).rejects.toThrow() + await expect(axiosApi.patch('/auth/me/password', {})).rejects.toThrow() + await expect(axiosApi.post('/auth/logout')).rejects.toThrow() expect(handler).not.toHaveBeenCalled() }) + + it('calls the handler for user and session admin requests under /auth', async () => { + const handler = vi.fn() + setSessionExpiredHandler(handler) + respond401() + + await expect(axiosApi.get('/auth/users')).rejects.toThrow() + await expect(axiosApi.get('/auth/users/7/sessions')).rejects.toThrow() + await expect(axiosApi.delete('/auth/sessions/3')).rejects.toThrow() + await expect(axiosApi.patch('/auth/me', {})).rejects.toThrow() + expect(handler).toHaveBeenCalledTimes(4) + }) }) diff --git a/packages/web/src/lib/api/client.ts b/packages/web/src/lib/api/client.ts index 1661048..bb03830 100644 --- a/packages/web/src/lib/api/client.ts +++ b/packages/web/src/lib/api/client.ts @@ -35,7 +35,7 @@ type SessionExpiredHandler = () => void let sessionExpiredHandler: SessionExpiredHandler | null = null /** - * Called when a non-auth request gets 401 (session expired or revoked). The user context + * Called when a data request gets 401 (session expired or revoked). The user context * registers a handler that signs the user out, so the app redirects to login instead of * showing "Could not load" everywhere. */ @@ -43,8 +43,25 @@ export function setSessionExpiredHandler(handler: SessionExpiredHandler | null): sessionExpiredHandler = handler } -function isAuthEndpoint(url: string | undefined): boolean { - return !!url && /^\/?auth\//.test(url.replace(/^\/api/, '')) +/** + * Auth requests whose 401 is an answer, not an expired session: bad credentials, a wrong + * current password, not signed in yet, or a session check. Other /auth/* requests (user + * and session admin, profile updates) are data requests. + */ +const SELF_REPORTING_AUTH_REQUESTS = new Set([ + 'POST /auth/login', + 'POST /auth/self-register', + 'POST /auth/logout', + 'POST /auth/switch', + 'GET /auth/me', + 'GET /auth/current', + 'PATCH /auth/me/password', +]) + +function reportsOwn401(method: string | undefined, url: string | undefined): boolean { + if (!url) return false + const path = url.split('?')[0].replace(/^\/?api(?=\/)/, '').replace(/^\/?/, '/').replace(/\/$/, '') + return SELF_REPORTING_AUTH_REQUESTS.has(`${(method ?? 'get').toUpperCase()} ${path}`) } function attachRequestIdInterceptor(instance: AxiosInstance): void { @@ -68,8 +85,7 @@ function attachRequestIdInterceptor(instance: AxiosInstance): void { if (axios.isAxiosError(error)) { const id = readRequestIdHeader(error.response?.headers) if (id) lastResponseRequestId = id - // Auth endpoints report their own 401s (wrong password, not logged in yet). - if (error.response?.status === 401 && !isAuthEndpoint(error.config?.url)) { + if (error.response?.status === 401 && !reportsOwn401(error.config?.method, error.config?.url)) { sessionExpiredHandler?.() } } From 34f1167d5313f7bb18d0150bd069b9e849e8f971 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:00:32 -0700 Subject: [PATCH 45/85] refactor(web): share the box and bag sheet list and expand state in useCollectionSheets. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/useCollectionSheets.test.ts | 42 +++++++++++++++ packages/web/src/hooks/useCollectionSheets.ts | 51 +++++++++++++++++++ packages/web/src/pages/BagDetail.tsx | 43 ++-------------- packages/web/src/pages/BoxDetail.tsx | 43 ++-------------- 4 files changed, 99 insertions(+), 80 deletions(-) create mode 100644 packages/web/src/hooks/__tests__/useCollectionSheets.test.ts create mode 100644 packages/web/src/hooks/useCollectionSheets.ts diff --git a/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts b/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts new file mode 100644 index 0000000..6925519 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useCollectionSheets } from '../useCollectionSheets' + +const contents = (count: number) => ({ sheets: Array.from({ length: count }, (_, i) => ({ id: i + 1, papers: [] })) }) + +describe('useCollectionSheets', () => { + it('expands every sheet when there are 3 or fewer', () => { + const { result } = renderHook(() => useCollectionSheets(1, contents(3))) + expect([...result.current.expandedSheets]).toEqual([1, 2, 3]) + }) + + it('starts collapsed when there are more than 3 sheets', () => { + const { result } = renderHook(() => useCollectionSheets(1, contents(4))) + expect(result.current.expandedSheets.size).toBe(0) + }) + + it('waits for the sheets to load, then keeps the user toggles across refetches', () => { + const { result, rerender } = renderHook(({ id, c }) => useCollectionSheets(id, c), { + initialProps: { id: 1, c: undefined as ReturnType | undefined }, + }) + expect(result.current.sheets).toEqual([]) + rerender({ id: 1, c: contents(2) }) + expect([...result.current.expandedSheets]).toEqual([1, 2]) + + act(() => result.current.toggleSheet(1)) + expect([...result.current.expandedSheets]).toEqual([2]) + rerender({ id: 1, c: contents(2) }) + expect([...result.current.expandedSheets]).toEqual([2]) + }) + + it('starts over for a different collection', () => { + const { result, rerender } = renderHook(({ id, c }) => useCollectionSheets(id, c), { + initialProps: { id: 1, c: contents(4) as ReturnType | undefined }, + }) + act(() => result.current.toggleSheet(2)) + rerender({ id: 2, c: undefined }) + expect(result.current.expandedSheets.size).toBe(0) + rerender({ id: 2, c: contents(1) }) + expect([...result.current.expandedSheets]).toEqual([1]) + }) +}) diff --git a/packages/web/src/hooks/useCollectionSheets.ts b/packages/web/src/hooks/useCollectionSheets.ts new file mode 100644 index 0000000..2547689 --- /dev/null +++ b/packages/web/src/hooks/useCollectionSheets.ts @@ -0,0 +1,51 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { CollectionTableEntry } from '../lib/collection-table-columns' +import { EMPTY_LIST } from '../lib/empty-list' + +export interface CollectionSheet { + id: number + name?: string + papers?: CollectionTableEntry[] +} + +/** + * The sheets of a box or bag, and which are expanded. When the sheets first load, 3 or fewer start + * expanded and more start collapsed; a new `collectionId` starts over. + */ +export function useCollectionSheets( + collectionId: number, + contents: { sheets: readonly unknown[] } | undefined, +) { + const [expandedSheets, setExpandedSheets] = useState>(new Set()) + const initializedSheets = useRef(false) + + useEffect(() => { + initializedSheets.current = false + setExpandedSheets(new Set()) + }, [collectionId]) + + const sheets = useMemo(() => (contents?.sheets ?? EMPTY_LIST) as CollectionSheet[], [contents]) + + useEffect(() => { + if (sheets.length > 0 && !initializedSheets.current) { + initializedSheets.current = true + if (sheets.length <= 3) { + setExpandedSheets(new Set(sheets.map((s) => s.id))) + } + } + }, [sheets]) + + const toggleSheet = (sheetId: number) => { + setExpandedSheets((prev) => { + const next = new Set(prev) + if (next.has(sheetId)) { + next.delete(sheetId) + } else { + next.add(sheetId) + } + return next + }) + } + + return { sheets, expandedSheets, toggleSheet } +} diff --git a/packages/web/src/pages/BagDetail.tsx b/packages/web/src/pages/BagDetail.tsx index 4579511..ae2eb7b 100644 --- a/packages/web/src/pages/BagDetail.tsx +++ b/packages/web/src/pages/BagDetail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from 'react' +import { useMemo, useState } from 'react' import { useParams, Link, useNavigate } from 'react-router-dom' import EntityBreadcrumbs from '../components/EntityBreadcrumbs' import CollectionTableWithExport from '../components/CollectionTableWithExport' @@ -12,6 +12,7 @@ import { type CollectionTableEntry, } from '../lib/collection-table-columns' import { useTableViewConfigurations } from '../hooks/useTableViewConfigurations' +import { useCollectionSheets } from '../hooks/useCollectionSheets' import CollectionDeleteDialog from '../components/CollectionDeleteDialog' import '../styles/storage.css' @@ -24,8 +25,6 @@ export default function BagDetail() { const detailStatus = fromQuery(dataQuery) const [viewMode, setViewMode] = useState<'sheets' | 'table'>('sheets') const [showDeleteCollection, setShowDeleteCollection] = useState(false) - const [expandedSheets, setExpandedSheets] = useState>(new Set()) - const initializedSheets = useRef(false) const { configurations: viewConfigurations, selectedConfigId, @@ -33,31 +32,7 @@ export default function BagDetail() { loading: loadingConfigs, } = useTableViewConfigurations() - useEffect(() => { - initializedSheets.current = false - setExpandedSheets(new Set()) - }, [bagId]) - - // Initialize expanded sheets (default to collapsed if more than 3 sheets) - // This hook must be called before any conditional returns to maintain hook order - const sheets = useMemo( - () => - (data?.contents.sheets || []) as Array<{ - id: number - name?: string - papers?: CollectionTableEntry[] - }>, - [data], - ) - useEffect(() => { - if (sheets.length > 0 && !initializedSheets.current) { - initializedSheets.current = true - // Default to expanded if 3 or fewer sheets, otherwise collapsed - if (sheets.length <= 3) { - setExpandedSheets(new Set(sheets.map((s: any) => s.id))) - } - } - }, [sheets]) + const { sheets, expandedSheets, toggleSheet } = useCollectionSheets(bagId, data?.contents) const tableRows = useMemo(() => { const bag = data?.bag @@ -147,18 +122,6 @@ export default function BagDetail() { sum + (sheet.papers?.filter((p: any) => p.container?.remainingQuantity > 0).length || 0), 0 ) - const toggleSheet = (sheetId: number) => { - setExpandedSheets(prev => { - const next = new Set(prev) - if (next.has(sheetId)) { - next.delete(sheetId) - } else { - next.add(sheetId) - } - return next - }) - } - return (
diff --git a/packages/web/src/pages/BoxDetail.tsx b/packages/web/src/pages/BoxDetail.tsx index b9549b0..cddc425 100644 --- a/packages/web/src/pages/BoxDetail.tsx +++ b/packages/web/src/pages/BoxDetail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from 'react' +import { useMemo, useState } from 'react' import { useParams, Link, useNavigate } from 'react-router-dom' import EntityBreadcrumbs from '../components/EntityBreadcrumbs' import CollectionTableWithExport from '../components/CollectionTableWithExport' @@ -12,6 +12,7 @@ import { type CollectionTableEntry, } from '../lib/collection-table-columns' import { useTableViewConfigurations } from '../hooks/useTableViewConfigurations' +import { useCollectionSheets } from '../hooks/useCollectionSheets' import CollectionDeleteDialog from '../components/CollectionDeleteDialog' import '../styles/storage.css' @@ -24,8 +25,6 @@ export default function BoxDetail() { const detailStatus = fromQuery(dataQuery) const [viewMode, setViewMode] = useState<'sheets' | 'table'>('sheets') const [showDeleteCollection, setShowDeleteCollection] = useState(false) - const [expandedSheets, setExpandedSheets] = useState>(new Set()) - const initializedSheets = useRef(false) const { configurations: viewConfigurations, selectedConfigId, @@ -33,31 +32,7 @@ export default function BoxDetail() { loading: loadingConfigs, } = useTableViewConfigurations() - useEffect(() => { - initializedSheets.current = false - setExpandedSheets(new Set()) - }, [boxId]) - - // Initialize expanded sheets (default to collapsed if more than 3 sheets) - // This hook must be called before any conditional returns to maintain hook order - const sheets = useMemo( - () => - (data?.contents.sheets || []) as Array<{ - id: number - name?: string - papers?: CollectionTableEntry[] - }>, - [data], - ) - useEffect(() => { - if (sheets.length > 0 && !initializedSheets.current) { - initializedSheets.current = true - // Default to expanded if 3 or fewer sheets, otherwise collapsed - if (sheets.length <= 3) { - setExpandedSheets(new Set(sheets.map((s: any) => s.id))) - } - } - }, [sheets]) + const { sheets, expandedSheets, toggleSheet } = useCollectionSheets(boxId, data?.contents) const tableRows = useMemo(() => { const box = data?.box @@ -150,18 +125,6 @@ export default function BoxDetail() { sum + (sheet.papers?.filter((p: any) => p.container?.remainingQuantity > 0).length || 0), 0 ) - const toggleSheet = (sheetId: number) => { - setExpandedSheets(prev => { - const next = new Set(prev) - if (next.has(sheetId)) { - next.delete(sheetId) - } else { - next.add(sheetId) - } - return next - }) - } - return (
From a9f4dad7d832075dc8e739ab931781dbb9290ff7 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:01:21 -0700 Subject: [PATCH 46/85] fix(web): apply /specimens filters with Apply, so Back undoes one change. Every filter edit replaced the URL as it happened, which left Apply always disabled and gave Back nothing to undo. Filter edits now stay local until Apply, which adds one history entry. Search typing still applies live and replaces the entry, and keeps unapplied edits. Clear All still clears. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/SpecimenFilter.tsx | 33 ++++++-- packages/web/src/pages/Specimens.tsx | 11 +-- .../src/pages/__tests__/Specimens.test.tsx | 82 ++++++++++++++++++- 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/SpecimenFilter.tsx b/packages/web/src/components/SpecimenFilter.tsx index 6874cb4..3fa0b6a 100644 --- a/packages/web/src/components/SpecimenFilter.tsx +++ b/packages/web/src/components/SpecimenFilter.tsx @@ -17,19 +17,37 @@ export interface SpecimenFilters { } interface SpecimenFilterProps { + /** The applied filters. */ filters: SpecimenFilters - onChange: (filters: SpecimenFilters) => void + /** Search text applies as it is typed; other filter edits wait for Apply. */ + onSearchChange: (search: string | undefined) => void + /** Apply the edited filters (Apply, Enter in search, or Clear). */ onSubmit: (filters: SpecimenFilters) => void isLoading?: boolean } +const FILTER_KEYS: Array = [ + 'study', + 'sourceType', + 'specimenTypeId', + 'collectionDateFrom', + 'collectionDateTo', + 'createdFrom', + 'createdTo', + 'search', +] + +function sameFilters(a: SpecimenFilters, b: SpecimenFilters, ignore?: keyof SpecimenFilters): boolean { + return FILTER_KEYS.every((key) => key === ignore || (a[key] || undefined) === (b[key] || undefined)) +} + const SOURCE_TYPES = [ { value: '', label: 'All Sources' }, { value: 'subject', label: 'Subject' }, { value: 'control', label: 'Control' }, ] -export default function SpecimenFilter({ filters, onChange, onSubmit, isLoading = false }: SpecimenFilterProps) { +export default function SpecimenFilter({ filters, onSearchChange, onSubmit, isLoading = false }: SpecimenFilterProps) { const [localFilters, setLocalFilters] = useState(filters) const prevFiltersRef = useRef(filters) const [studies, setStudies] = useState([]) @@ -39,10 +57,12 @@ export default function SpecimenFilter({ filters, onChange, onSubmit, isLoading const [studySearch, setStudySearch] = useState('') const [studyLoading, setStudyLoading] = useState(false) - // Sync local filters when external filters change (during render to avoid extra pass) + // Sync local filters when the applied filters change (during render to avoid extra pass). + // A live search edit changes only `search`, so unapplied edits to the other filters survive it. if (filters !== prevFiltersRef.current) { + const searchOnly = sameFilters(filters, prevFiltersRef.current, 'search') prevFiltersRef.current = filters - setLocalFilters(filters) + setLocalFilters(searchOnly ? { ...localFilters, search: filters.search } : filters) } useEffect(() => { @@ -111,7 +131,7 @@ export default function SpecimenFilter({ filters, onChange, onSubmit, isLoading newFilters[key] = value } setLocalFilters(newFilters) - onChange(newFilters) + if (key === 'search') onSearchChange(newFilters.search) } const handleSubmit = () => { @@ -121,13 +141,12 @@ export default function SpecimenFilter({ filters, onChange, onSubmit, isLoading const clearFilters = () => { const emptyFilters: SpecimenFilters = {} setLocalFilters(emptyFilters) - onChange(emptyFilters) onSubmit(emptyFilters) } const activeFilterCount = Object.entries(localFilters).filter(([k, v]) => k !== 'search' && v !== undefined && v !== null && v !== '').length const hasActiveFilters = activeFilterCount > 0 - const hasChanges = JSON.stringify(localFilters) !== JSON.stringify(filters) + const hasChanges = !sameFilters(localFilters, filters) return (
diff --git a/packages/web/src/pages/Specimens.tsx b/packages/web/src/pages/Specimens.tsx index efbc8d9..92925b0 100644 --- a/packages/web/src/pages/Specimens.tsx +++ b/packages/web/src/pages/Specimens.tsx @@ -71,9 +71,8 @@ export default function Specimens() { isEmpty: specimensQuery.isSuccess && specimens.length === 0, }) + /** Write filters to the URL: a new history entry (so Back undoes it), or a replaced one. */ const handleFilterChange = (newFilters: SpecimenFilters, options?: { replace?: boolean }) => { - setPage(1) - const params: Record = {} if (newFilters.study) params.study = newFilters.study if (newFilters.sourceType) params.source_type = newFilters.sourceType @@ -84,6 +83,8 @@ export default function Specimens() { if (newFilters.createdTo) params.created_to = newFilters.createdTo if (newFilters.search) params.search = newFilters.search + // Applying unchanged filters must not add a history entry that Back would have to skip. + if (new URLSearchParams(params).toString() === searchParams.toString()) return setSearchParams(params, { replace: options?.replace }) } @@ -177,9 +178,9 @@ export default function Specimens() {
handleFilterChange(next, { replace: true })} - onSubmit={handleFilterChange} + // Search typing replaces the history entry instead of adding one per keystroke. + onSearchChange={(search) => handleFilterChange({ ...filters, search }, { replace: true })} + onSubmit={(next) => handleFilterChange(next)} isLoading={specimensQuery.isPending} />
diff --git a/packages/web/src/pages/__tests__/Specimens.test.tsx b/packages/web/src/pages/__tests__/Specimens.test.tsx index 153c682..7f9a61f 100644 --- a/packages/web/src/pages/__tests__/Specimens.test.tsx +++ b/packages/web/src/pages/__tests__/Specimens.test.tsx @@ -1,7 +1,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '../../__tests__/helpers/render' +import { useLocation, useNavigate } from 'react-router-dom' +import { render, screen, fireEvent, waitFor } from '../../__tests__/helpers/render' import Specimens from '../Specimens' +function BackButton() { + const navigate = useNavigate() + return +} + +function CurrentUrl() { + const location = useLocation() + return {location.pathname + location.search} +} + +async function renderAt(...initialEntries: string[]) { + await render( + <> + + + + , + { initialEntries }, + ) + return { + url: () => screen.getByLabelText('url').textContent, + sourceType: () => screen.getByLabelText('Source Type'), + search: () => screen.getByPlaceholderText(/Search subject/), + } +} + vi.mock('../../contexts/UserContext', async () => { const actual = await vi.importActual('../../contexts/UserContext') return { @@ -64,3 +91,56 @@ describe('Specimens page', () => { expect(screen.getAllByText(/Whole Blood/i).length).toBeGreaterThan(0) }) }) + +describe('Specimens page URL filters', () => { + beforeEach(() => { + vi.mocked(api.get).mockResolvedValue({ specimens: [] }) + }) + + it('keeps filter edits local until Apply, and Back undoes the applied change', async () => { + const page = await renderAt('/specimens') + + fireEvent.change(page.sourceType(), { target: { value: 'subject' } }) + expect(page.url()).toBe('/specimens') + + fireEvent.click(screen.getByRole('button', { name: /apply filters/i })) + await waitFor(() => expect(page.url()).toBe('/specimens?source_type=subject')) + + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + await waitFor(() => expect(page.url()).toBe('/specimens')) + expect(page.sourceType()).toHaveValue('') + }) + + it('applies search as it is typed without adding a history entry per keystroke', async () => { + const page = await renderAt('/start', '/specimens') + + fireEvent.change(page.search(), { target: { value: 'S' } }) + fireEvent.change(page.search(), { target: { value: 'SU' } }) + await waitFor(() => expect(page.url()).toBe('/specimens?search=SU')) + + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + await waitFor(() => expect(page.url()).toBe('/start')) + }) + + it('keeps unapplied filter edits while a search is typed', async () => { + const page = await renderAt('/specimens') + + fireEvent.change(page.sourceType(), { target: { value: 'control' } }) + fireEvent.change(page.search(), { target: { value: 'QC' } }) + await waitFor(() => expect(page.url()).toBe('/specimens?search=QC')) + expect(page.sourceType()).toHaveValue('control') + + fireEvent.click(screen.getByRole('button', { name: /apply filters/i })) + await waitFor(() => expect(page.url()).toBe('/specimens?source_type=control&search=QC')) + }) + + it('clears applied filters with Clear All', async () => { + const page = await renderAt('/specimens?source_type=subject') + expect(page.sourceType()).toHaveValue('subject') + + fireEvent.click(screen.getByRole('button', { name: 'Clear All' })) + + await waitFor(() => expect(page.url()).toBe('/specimens')) + expect(page.sourceType()).toHaveValue('') + }) +}) From 244e1a11e95b279cabdce2676c8ebf37d2a45e49 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:01:45 -0700 Subject: [PATCH 47/85] fix(registration): reject a location that contradicts the collection's own. A new plate given two locations failed only within one registerSpecimens call. Control batches and /specimens/bulk register one container per call, so the second call found the plate the first had created and ignored its location. The planner now checks a given location against the stored Collection as well as a planned one, for plates, boxes, and bags, and ADR 0010 states the rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0010-specimen-registration.md | 2 +- .../__tests__/batch-with-specimens.test.ts | 29 ++++++++++- .../__tests__/specimen-registration.test.ts | 33 ++++++++++++ .../lib/specimen-registration/collections.ts | 50 +++++++++++-------- 4 files changed, 92 insertions(+), 22 deletions(-) diff --git a/docs/adr/0010-specimen-registration.md b/docs/adr/0010-specimen-registration.md index db29e53..cd48382 100644 --- a/docs/adr/0010-specimen-registration.md +++ b/docs/adr/0010-specimen-registration.md @@ -18,7 +18,7 @@ We put **Specimen registration** (see [CONTEXT.md](../../CONTEXT.md)) behind one ## Rules the module owns - **Specimen identity**: (Source, specimen type, collection date), a missing date matching only a missing date. A match is reused and reported as `reused`; entries with the same key in one request become one Specimen. -- **Collection identity**: every identifier given (id, barcode, name) must resolve to the same **Collection**. One that misses while another matches, or two that match different collections, is an error. A Collection is created only when nothing matches and a location is given. Sheets are created under their box or bag when missing. +- **Collection identity**: every identifier given (id, barcode, name) must resolve to the same **Collection**. One that misses while another matches, or two that match different collections, is an error. A Collection is created only when nothing matches and a location is given. A location given for a Collection that exists, or that the request already creates, must be the one it has, so one new plate given two locations fails however the request is split into calls. Sheets are created under their box or bag when missing. - **Placement**: one **Grid position** per container per collection, checked against the database and the rest of the request by resolved collection, after normalizing positions (`A2` → `A02`). Any number of Papers share a Sheet; a duplicate sublabel on one Sheet is an error. - **Units**: an omitted unit takes the configured default for the container type; an unknown or disallowed unit is an error. No hardcoded container-type default. - **Atomicity**: one call writes everything or nothing. Atomicity across calls is the caller's choice: bulk combined `per_subject` mode registers each subject in its own `withWriteTransaction`, which becomes a savepoint when an outer transaction is open, so a failed subject leaves nothing behind, its Subject row included. diff --git a/packages/api/src/lib/controls/__tests__/batch-with-specimens.test.ts b/packages/api/src/lib/controls/__tests__/batch-with-specimens.test.ts index d0265e3..dcb3833 100644 --- a/packages/api/src/lib/controls/__tests__/batch-with-specimens.test.ts +++ b/packages/api/src/lib/controls/__tests__/batch-with-specimens.test.ts @@ -12,7 +12,7 @@ import { import { setContainerDefaults, clearSettingsCache } from '../../settings' import { clearDefaultsCache } from '../../defaults' import { createBatchWithSpecimens, createBatchesWithSpecimens } from '../batch-with-specimens' -import { specimenTypeContainerType, containerTypeUnit, storageContainer, box, controlBatch } from '../../../db/schema' +import { specimenTypeContainerType, containerTypeUnit, storageContainer, box, controlBatch, micronixPlate } from '../../../db/schema' import { eq } from 'drizzle-orm' import type { Database } from '../../../db/client' import { utcNow } from '../../datetime' @@ -127,6 +127,33 @@ describe('batch-with-specimens', () => { expect(result.specimens[0].containerIds).toHaveLength(1) }) + it('rejects one new plate given two locations, though each container is registered on its own', async () => { + const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' }) + await setContainerDefaults(testDb, { micronix_tube: { totalQuantity: 1, remainingQuantity: 1, defaultUnitSymbol: 'uL' } }) + await testDb.insert(containerTypeUnit).values({ containerType: 'micronix_tube', unitId: unit.id }) + const definition = await createTestControlDefinition(testDb, { name: 'Def1' }) + const specimenType = await createTestSpecimenType(testDb, { name: 'DNA' }) + await testDb.insert(specimenTypeContainerType).values({ specimenTypeId: specimenType.id, containerType: 'micronix_tube', created: utcNow() }) + const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) + const shelf = await createTestLocation(testDb, { name: 'Shelf', storageTypeId: String(storageType.id), canContainCollections: true }) + const rack = await createTestLocation(testDb, { name: 'Rack', storageTypeId: String(storageType.id), canContainCollections: true }) + const onNewPlate = (barcode: string, locationId: number, position: string) => ({ + containerType: 'micronix_tube' as const, + barcode, + collection: { type: 'micronix_plate' as const, name: 'NEW-PLATE', locationId, position }, + }) + + await expect( + createBatchWithSpecimens(testDb, { + batch: { controlDefinitionId: definition.id, name: 'Batch1' }, + specimens: [{ specimenTypeName: 'DNA', containers: [onNewPlate('MT001', shelf.id, 'A01'), onNewPlate('MT002', rack.id, 'A02')] }], + }) + ).rejects.toThrow("Micronix plate 'NEW-PLATE' is stored at a different location from the one given.") + + expect(await testDb.select().from(controlBatch)).toEqual([]) + expect(await testDb.select().from(micronixPlate)).toEqual([]) + }) + it('throws when paper container is missing sheet name', async () => { const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' }) await setContainerDefaults(testDb, { diff --git a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts index feeb6a0..273e508 100644 --- a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts +++ b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts @@ -237,6 +237,39 @@ describe('Specimen registration', () => { }) }) + describe('Collection location', () => { + let other: number + + beforeEach(async () => { + const storageType = await createTestStorageType(db, { name: 'Fridge' }) + other = (await createTestLocation(db, { name: 'Other', storageTypeId: String(storageType.id), canContainCollections: true })).id + }) + + it('rejects a new plate given another location by a later call', async () => { + await registerSpecimens(db, [entry([tube('T1', { name: 'NEW', locationId, position: 'A01' })])]) + + const error = await rejection([entry([tube('T2', { name: 'NEW', locationId: other, position: 'A02' })])]) + + expect(error.errors[0]).toMatchObject({ field: 'collection', message: "Micronix plate 'NEW' is stored at a different location from the one given." }) + }) + + it('rejects a box given another location than the one it is stored at', async () => { + await registerSpecimens(db, [entry([paperOn('Sheet-1')])]) + + const error = await rejection([ + entry([{ containerType: 'paper', collection: { type: 'sheet', name: 'Sheet-2', parent: { type: 'box', name: 'PaperBox', locationId: other } } }]), + ]) + + expect(error.errors[0].message).toBe("Box 'PaperBox' is stored at a different location from the one given.") + }) + + it('accepts an existing plate given its own location or none', async () => { + await expect( + registerSpecimens(db, [entry([tube('T1', { name: 'P1', locationId, position: 'A02' }), tube('T2', { name: 'P1', position: 'A03' })])]), + ).resolves.toBeDefined() + }) + }) + describe('Paper on Sheets', () => { it('puts several papers on one new sheet', async () => { const result = await registerSpecimens(db, [ diff --git a/packages/api/src/lib/specimen-registration/collections.ts b/packages/api/src/lib/specimen-registration/collections.ts index 9c30c10..7cbe0a1 100644 --- a/packages/api/src/lib/specimen-registration/collections.ts +++ b/packages/api/src/lib/specimen-registration/collections.ts @@ -32,7 +32,13 @@ export type PlannedCollection = export type Resolution = { target: CollectionTarget } | { error: string } -type Candidate = { target: CollectionTarget; name: string; barcode: string | null } +type Candidate = { + target: CollectionTarget + type: GridCollectionType | ParentCollectionType + name: string + barcode: string | null + locationId: number | null +} const LABELS: Record = { micronix_plate: 'Micronix plate', @@ -78,21 +84,25 @@ export class CollectionPlanner { if (planned.type === type && match(planned)) { out.push({ target: { plannedIndex }, + type, name: planned.name, barcode: 'barcode' in planned ? (planned.barcode ?? null) : null, + locationId: 'locationId' in planned ? planned.locationId : null, }) } }) return out } - private plannedLocationConflict(target: CollectionTarget, locationId: number | undefined): string | null { - if (!('plannedIndex' in target) || locationId == null) return null - const planned = this.planned[target.plannedIndex] - if ('locationId' in planned && planned.locationId !== locationId) { - return `${LABELS[planned.type]} '${planned.name}' is given two different locations in this request.` - } - return null + /** + * A location given for a Collection that exists or is already planned must be the one it has. + * Checking stored Collections too makes the rule hold when a request is split over several calls. + */ + private locationConflict(match: Candidate, locationId: number | undefined): string | null { + if (locationId == null || match.locationId === locationId) return null + return 'plannedIndex' in match.target + ? `${LABELS[match.type]} '${match.name}' is given two different locations in this request.` + : `${LABELS[match.type]} '${match.name}' is stored at a different location from the one given.` } async resolveGrid(type: GridCollectionType, collection: GridCollectionInput): Promise { @@ -102,31 +112,31 @@ export class CollectionPlanner { if (collection.id != null) { const row = await this.database - .select({ id: table.id, name: table.name, barcode: table.barcode }) + .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) .from(table) .where(eq(table.id, collection.id)) .get() if (!row) return { error: `${label} not found: id ${collection.id}` } - candidates.push({ target: { existingId: row.id }, name: row.name, barcode: row.barcode }) + candidates.push({ target: { existingId: row.id }, type, ...row }) } if (collection.barcode) { const barcode = collection.barcode const row = await this.database - .select({ id: table.id, name: table.name, barcode: table.barcode }) + .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) .from(table) .where(eq(table.barcode, barcode)) .get() - if (row) candidates.push({ target: { existingId: row.id }, name: row.name, barcode: row.barcode }) + if (row) candidates.push({ target: { existingId: row.id }, type, ...row }) else candidates.push(...this.plannedCandidates(type, (p) => 'barcode' in p && p.barcode === barcode)) } if (collection.name) { const name = collection.name const row = await this.database - .select({ id: table.id, name: table.name, barcode: table.barcode }) + .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) .from(table) .where(eq(table.name, name)) .get() - if (row) candidates.push({ target: { existingId: row.id }, name: row.name, barcode: row.barcode }) + if (row) candidates.push({ target: { existingId: row.id }, type, ...row }) else candidates.push(...this.plannedCandidates(type, (p) => p.name === name)) } @@ -149,7 +159,7 @@ export class CollectionPlanner { const found = collection.id != null ? `${label} id ${collection.id}` : `${label} with barcode '${collection.barcode ?? ''}'` return { error: `${found} is named '${match.name}', not '${collection.name}'.` } } - const conflict = this.plannedLocationConflict(match.target, collection.locationId) + const conflict = this.locationConflict(match, collection.locationId) if (conflict) return { error: conflict } return { target: match.target } } @@ -180,21 +190,21 @@ export class CollectionPlanner { if (parent.id != null) { const row = await this.database - .select({ id: table.id, name: table.name }) + .select({ id: table.id, name: table.name, locationId: table.locationId }) .from(table) .where(eq(table.id, parent.id)) .get() if (!row) return { error: `${label} not found: id ${parent.id}` } - candidates.push({ target: { existingId: row.id }, name: row.name, barcode: null }) + candidates.push({ target: { existingId: row.id }, type, barcode: null, ...row }) } if (parent.name) { const name = parent.name const row = await this.database - .select({ id: table.id, name: table.name }) + .select({ id: table.id, name: table.name, locationId: table.locationId }) .from(table) .where(eq(table.name, name)) .get() - if (row) candidates.push({ target: { existingId: row.id }, name: row.name, barcode: null }) + if (row) candidates.push({ target: { existingId: row.id }, type, barcode: null, ...row }) else candidates.push(...this.plannedCandidates(type, (p) => p.name === name)) } @@ -204,7 +214,7 @@ export class CollectionPlanner { if (matches.length > 1 || (parent.name && match.name !== parent.name)) { return { error: `${label} id ${parent.id ?? ''} is named '${match.name}', not '${parent.name ?? ''}'.` } } - const conflict = this.plannedLocationConflict(match.target, parent.locationId) + const conflict = this.locationConflict(match, parent.locationId) if (conflict) return { error: conflict } return { target: match.target } } From 83fd885e66f0705303db208e0ff248ba7422be04 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:02:38 -0700 Subject: [PATCH 48/85] fix(web): rerun the batch-definition and allowed-container-type effects only on their original triggers. Moving these effects onto the full batchInfo and onto containerType made them run on unrelated edits. Their bodies now read the rest through useEffectEvent. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/ContainerRegistration.tsx | 10 +++-- .../__tests__/ContainerRegistration.test.tsx | 42 +++++++++++++++++++ .../src/components/wizards/BatchInfoStep.tsx | 10 +++-- .../wizards/__tests__/BatchInfoStep.test.tsx | 22 +++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/components/__tests__/ContainerRegistration.test.tsx diff --git a/packages/web/src/components/ContainerRegistration.tsx b/packages/web/src/components/ContainerRegistration.tsx index f2dd06d..6f5beec 100644 --- a/packages/web/src/components/ContainerRegistration.tsx +++ b/packages/web/src/components/ContainerRegistration.tsx @@ -133,14 +133,18 @@ export default function ContainerRegistration({ } } - // When allowed types are restricted and current type is not allowed, reset to first allowed - useEffect(() => { + // When allowed types are restricted and current type is not allowed, reset to first allowed. + // Runs when the allowed list changes; the select only offers allowed types. + const resetDisallowedContainerType = useEffectEvent(() => { if (allowedContainerTypes.length > 0 && !allowedContainerTypes.includes(containerType)) { const first = allowedContainerTypes[0] as ContainerType setContainerType(first) setFormData((prev) => ({ ...prev, containerType: first })) } - }, [allowedContainerTypes, containerType]) + }) + useEffect(() => { + resetDisallowedContainerType() + }, [allowedContainerTypes]) // Read at use time, so a unit picked while defaults load is kept. const hasChosenUnit = useEffectEvent(() => Boolean(selectedUnitId || defaultValue?.unitId)) diff --git a/packages/web/src/components/__tests__/ContainerRegistration.test.tsx b/packages/web/src/components/__tests__/ContainerRegistration.test.tsx new file mode 100644 index 0000000..773ffb1 --- /dev/null +++ b/packages/web/src/components/__tests__/ContainerRegistration.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '../../__tests__/helpers/render' +import ContainerRegistration from '../ContainerRegistration' + +vi.mock('../../lib/api/settings', async () => { + const { createMockedDomainModule } = await import('../../__tests__/helpers/mock-api') + return createMockedDomainModule('settings', { + settingsApi: { + getValue: vi.fn().mockResolvedValue(null), + getContainerTypeUnits: vi.fn().mockResolvedValue({ units: [] }), + }, + }) +}) + +vi.mock('../../lib/api/collections', async () => { + const { createMockedDomainModule } = await import('../../__tests__/helpers/mock-api') + return createMockedDomainModule('collections', { + collectionsApi: { listCollectionsByType: vi.fn().mockResolvedValue({ collections: [] }) }, + }) +}) + +const containerTypeSelect = () => screen.getByText('Container Type *').parentElement?.querySelector('select') + +vi.mock('../../lib/api/locations', async () => { + const { createMockedDomainModule } = await import('../../__tests__/helpers/mock-api') + return createMockedDomainModule('locations', { + locationsApi: { list: vi.fn().mockResolvedValue({ locations: [] }) }, + }) +}) + +describe('ContainerRegistration', () => { + it('switches to the first allowed container type when the allowed list excludes the current one', async () => { + const onChange = vi.fn() + const { rerender } = await render( + , + ) + await waitFor(() => expect(containerTypeSelect()).toHaveValue('cryovial_tube')) + + rerender() + await waitFor(() => expect(containerTypeSelect()).toHaveValue('paper')) + }) +}) diff --git a/packages/web/src/components/wizards/BatchInfoStep.tsx b/packages/web/src/components/wizards/BatchInfoStep.tsx index f9a888f..d904691 100644 --- a/packages/web/src/components/wizards/BatchInfoStep.tsx +++ b/packages/web/src/components/wizards/BatchInfoStep.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useEffectEvent, useRef, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { controlsApi } from '../../lib/api/controls' import type { ControlDefinition } from '../../lib/api/controls' @@ -74,12 +74,16 @@ export default function BatchInfoStep({ } }, [definitionsQuery.data]) + // Attach the loaded definition once; edits to other batch fields don't re-run this. + const attachDefinition = useEffectEvent((def: ControlDefinition) => { + onChange({ ...batchInfo, controlDefinition: def }) + }) useEffect(() => { const def = definitionDetailQuery.data if (def && batchInfo.controlDefinitionId && !batchInfo.controlDefinition) { - onChange({ ...batchInfo, controlDefinition: def }) + attachDefinition(def) } - }, [definitionDetailQuery.data, batchInfo, onChange]) + }, [definitionDetailQuery.data, batchInfo.controlDefinitionId, batchInfo.controlDefinition]) // Client-side filter by search, strains, and density const filteredDefinitions = useMemo(() => { diff --git a/packages/web/src/components/wizards/__tests__/BatchInfoStep.test.tsx b/packages/web/src/components/wizards/__tests__/BatchInfoStep.test.tsx index 1ebb56c..c34171b 100644 --- a/packages/web/src/components/wizards/__tests__/BatchInfoStep.test.tsx +++ b/packages/web/src/components/wizards/__tests__/BatchInfoStep.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '../../../__tests__/helpers/render' +import { render, screen, waitFor } from '../../../__tests__/helpers/render' +import { controlsApi } from '../../../lib/api/controls' import BatchInfoStep from '../BatchInfoStep' vi.mock('../../../lib/api/reference-data', async () => { @@ -20,6 +21,7 @@ vi.mock('../../../lib/api/controls', async () => { return createMockedDomainModule('controls', { controlsApi: { list: vi.fn().mockResolvedValue({ controls: [] }), + get: vi.fn(), getDefinitionSummary: vi.fn(), }, strainsApi: { @@ -65,4 +67,22 @@ describe('BatchInfoStep', () => { expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /next|continue/i })).toBeInTheDocument() }) + + it('attaches the loaded definition once, not again when another batch field changes', async () => { + vi.mocked(controlsApi.get).mockResolvedValue({ + control: { id: 5, name: 'Def 5', controlType: 'blood' }, + } as unknown as Awaited>) + const onChange = vi.fn() + const props = { onChange, onNext: vi.fn(), onCancel: vi.fn(), isAddMode: false } + const batchInfo = { ...defaultBatchInfo, controlDefinitionId: 5 } + const { rerender } = await render() + + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)) + expect(onChange.mock.calls[0]?.[0]).toMatchObject({ controlDefinitionId: 5, controlDefinition: { id: 5 } }) + + // The parent hasn't stored the definition yet, but only the name changed. + rerender() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onChange).toHaveBeenCalledTimes(1) + }) }) From c1d105f31aeafc35bb0a126873a7d60c43e5ef79 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:03:15 -0700 Subject: [PATCH 49/85] fix(web): set up box and bag sheet expansion on load or collection change, not every refetch. The PR moved this effect from the sheet count to the whole sheet list, so it ran on every refetch. The count alone missed a collection whose sheets were already cached with the same count, so it now runs on the collection id and the count. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/hooks/__tests__/useCollectionSheets.test.ts | 9 +++++++++ packages/web/src/hooks/useCollectionSheets.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts b/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts index 6925519..7bcad19 100644 --- a/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts +++ b/packages/web/src/hooks/__tests__/useCollectionSheets.test.ts @@ -39,4 +39,13 @@ describe('useCollectionSheets', () => { rerender({ id: 2, c: contents(1) }) expect([...result.current.expandedSheets]).toEqual([1]) }) + + it('initializes a collection whose sheets are already cached, even with the same sheet count', () => { + const cached = { sheets: [{ id: 10 }, { id: 11 }] } + const { result, rerender } = renderHook(({ id, c }) => useCollectionSheets(id, c), { + initialProps: { id: 1, c: contents(2) as { sheets: { id: number }[] } }, + }) + rerender({ id: 2, c: cached }) + expect([...result.current.expandedSheets]).toEqual([10, 11]) + }) }) diff --git a/packages/web/src/hooks/useCollectionSheets.ts b/packages/web/src/hooks/useCollectionSheets.ts index 2547689..a083a30 100644 --- a/packages/web/src/hooks/useCollectionSheets.ts +++ b/packages/web/src/hooks/useCollectionSheets.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import type { CollectionTableEntry } from '../lib/collection-table-columns' import { EMPTY_LIST } from '../lib/empty-list' @@ -26,14 +26,19 @@ export function useCollectionSheets( const sheets = useMemo(() => (contents?.sheets ?? EMPTY_LIST) as CollectionSheet[], [contents]) - useEffect(() => { + const initializeExpanded = useEffectEvent(() => { if (sheets.length > 0 && !initializedSheets.current) { initializedSheets.current = true if (sheets.length <= 3) { setExpandedSheets(new Set(sheets.map((s) => s.id))) } } - }, [sheets]) + }) + // Runs when the sheets first load or the collection changes (its sheets may already be cached), + // not on every refetch. + useEffect(() => { + initializeExpanded() + }, [collectionId, sheets.length]) const toggleSheet = (sheetId: number) => { setExpandedSheets((prev) => { From e8ae8fe192e63d2a81e4fa148ad02b16a5da926a Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:03:49 -0700 Subject: [PATCH 50/85] fix(api): drop the remaining study-sized IN lists from stats and export. The dashboard's study filter listed every subject id, and the container export listed every subject in the study (or the upload) and every undated subject, so a study past ~65k subjects failed. The dashboard and whole-study exports select subjects with a subquery; listed subjects are queried a chunk at a time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/export/__tests__/query.test.ts | 60 ++++++++ packages/api/src/lib/export/query.ts | 133 ++++++++---------- .../__tests__/dashboard-stats.test.ts | 24 ++++ .../__tests__/specimen-aggregates.test.ts | 8 +- .../api/src/lib/statistics/dashboard-stats.ts | 29 ++-- .../src/lib/statistics/specimen-aggregates.ts | 64 ++++----- 6 files changed, 186 insertions(+), 132 deletions(-) diff --git a/packages/api/src/lib/export/__tests__/query.test.ts b/packages/api/src/lib/export/__tests__/query.test.ts index 4542592..f12399e 100644 --- a/packages/api/src/lib/export/__tests__/query.test.ts +++ b/packages/api/src/lib/export/__tests__/query.test.ts @@ -12,6 +12,7 @@ import { import type { Database } from '../../../db/client' import { storageContainerTag, specimen as specimenTable } from '../../../db/schema' import { utcNow } from '../../datetime' +import { eq } from 'drizzle-orm' import { buildContainerQuery, buildMultiStudyContainerQuery, @@ -123,6 +124,65 @@ describe('buildContainerQuery', () => { }) }) +describe('buildContainerQuery for studies with more subjects than SQLite allows as bound parameters', () => { + let testDb: Database + let sqlite: Awaited>['sqlite'] + const count = 70_000 + + beforeEach(async () => { + const setup = await setupTestDatabase() + testDb = setup.db + sqlite = setup.sqlite + }) + + afterEach(() => { + if (sqlite) cleanupTestDatabase(sqlite) + }) + + /** 70,000 subjects; the first three have one dated specimen and container each. */ + async function seedBigStudy() { + const study = await createTestStudy(testDb, { title: 'Big Study', shortCode: 'BIG' }) + const type = await createTestSpecimenType(testDb, { name: 'DBS' }) + const subjectIds: number[] = [] + sqlite.exec('BEGIN') + const addSubject = sqlite.prepare( + "INSERT INTO study_subject (study_id, name, created, last_updated) VALUES (?, ?, '2024-01-01', '2024-01-01')", + ) + for (let i = 0; i < count; i++) { + subjectIds.push(Number(addSubject.run(study.id, `S${i}`).lastInsertRowid)) + } + sqlite.exec('COMMIT') + const unit = await createTestUnit(testDb, { symbol: 'spots', name: 'spots', category: 'count' }) + for (const [i, subjectId] of subjectIds.slice(0, 3).entries()) { + const spec = await createTestSpecimen(testDb, type.id, { studySubjectId: subjectId }) + await testDb.update(specimenTable).set({ collectionDate: `2024-0${i + 1}-01` }).where(eq(specimenTable.id, spec.id)) + await createTestStorageContainer(testDb, { specimenId: spec.id, unitId: unit.id }) + } + return subjectIds + } + + it('exports the whole study', async () => { + await seedBigStudy() + + const result = await buildContainerQuery(testDb, { study: 'BIG' }) + + expect(result.containers).toHaveLength(3) + }, 60_000) + + it('exports a listed subject set with per-subject dates', async () => { + const subjectIds = await seedBigStudy() + + const result = await buildContainerQuery(testDb, { + study: 'BIG', + subject_ids: subjectIds, + subject_dates: { [subjectIds[0]]: [{ exact: '2024-01-01' }], [subjectIds[1]]: [{ exact: '2030-01-01' }] }, + }) + + // Subject 0 matches its date, subject 1 does not, subject 2 is listed without a date. + expect(result.specimens?.map((s) => s.studySubjectId).sort((a, b) => a! - b!)).toEqual([subjectIds[0], subjectIds[2]]) + }, 60_000) +}) + describe('resolveMicronixBarcodesToContainers', () => { let testDb: Database let sqlite: Awaited>['sqlite'] diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index 2fc3613..5ff12d0 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -6,7 +6,7 @@ import { study, micronixTube, } from '../../db/schema' -import { eq, and, or, inArray, gte, lte, sql } from 'drizzle-orm' +import { eq, and, or, inArray, gte, lte, sql, type SQL } from 'drizzle-orm' import { resolveSubjectsByStudyGrouped } from '../identifier-resolution' import { resolveContainerIdsWithAllTags } from '../container-tag-filter' import { enrichContainerData } from './enrich' @@ -36,54 +36,45 @@ export async function buildContainerQuery(database: Database, filters: ExportFil throw new Error(`Study with short code '${filters.study}' not found`) } - // Get subject IDs for this study - const subjects = await database + // A subject list (or per-subject dates) needs the study's subject ids; a whole-study export + // selects them with a subquery instead of an id list. + const studySubjectIds = database .select({ id: studySubject.id }) .from(studySubject) .where(eq(studySubject.studyId, studyRecord.id)) - const subjectIds = subjects.map(s => s.id) + const subjectDates = + filters.subject_dates && Object.keys(filters.subject_dates).length > 0 ? filters.subject_dates : undefined + const listsSubjects = (filters.subject_ids && filters.subject_ids.length > 0) || subjectDates !== undefined - // If filtering by specific subjects, intersect - let filteredSubjectIds = subjectIds - if (filters.subject_ids && filters.subject_ids.length > 0) { - filteredSubjectIds = subjectIds.filter(id => filters.subject_ids!.includes(id)) - } - - if (filteredSubjectIds.length === 0) { - return { containers: [], study: studyRecord, specimens: [] } - } - - // Build conditions for specimen query - const specimenConditions: any[] = [] - specimenConditions.push( - sql`${specimen.studySubjectId} IS NOT NULL` as any - ) - - if (filteredSubjectIds.length === 1) { - specimenConditions.push( - eq(specimen.studySubjectId, filteredSubjectIds[0]) - ) - } else { - specimenConditions.push( - inArray(specimen.studySubjectId, filteredSubjectIds) - ) + let filteredSubjectIds: number[] = [] + if (listsSubjects) { + filteredSubjectIds = (await studySubjectIds).map((s) => s.id) + if (filters.subject_ids && filters.subject_ids.length > 0) { + const requested = new Set(filters.subject_ids) + filteredSubjectIds = filteredSubjectIds.filter((id) => requested.has(id)) + } + if (filteredSubjectIds.length === 0) { + return { containers: [], study: studyRecord, specimens: [] } + } } + const specimenConditions: SQL[] = [sql`${specimen.studySubjectId} IS NOT NULL`] if (filters.specimen_type_ids && filters.specimen_type_ids.length > 0) { - specimenConditions.push( - inArray(specimen.specimenTypeId, filters.specimen_type_ids) - ) + specimenConditions.push(inArray(specimen.specimenTypeId, filters.specimen_type_ids)) } + const globalDateConditions = [ + ...(filters.date_from ? [gte(specimen.collectionDate, filters.date_from)] : []), + ...(filters.date_to ? [lte(specimen.collectionDate, filters.date_to)] : []), + ] - // Handle per-subject date filtering - if (filters.subject_dates && Object.keys(filters.subject_dates).length > 0) { + /** Subject (and collection date) condition for one chunk of listed subjects. */ + const subjectChunkCondition = (subjectIds: number[]): SQL | undefined => { + if (!subjectDates) return and(inArray(specimen.studySubjectId, subjectIds), ...globalDateConditions) const tolerance = filters.date_tolerance || 0 - const subjectDates = filters.subject_dates - const subjectDateConditions: any[] = [] + const conditions: SQL[] = [] const undatedSubjectIds: number[] = [] - - for (const subjectId of filteredSubjectIds) { + for (const subjectId of subjectIds) { const entry = subjectDates[subjectId] as SubjectDateFilter | SubjectDateFilter[] | undefined const dateConditions = (entry == null ? [] : Array.isArray(entry) ? entry : [entry]) .map((dateFilter) => subjectDateCondition(dateFilter, tolerance)) @@ -94,47 +85,43 @@ export async function buildContainerQuery(database: Database, filters: ExportFil continue } // Several entries for one subject (one per visit) are alternatives. - subjectDateConditions.push(and(eq(specimen.studySubjectId, subjectId), or(...dateConditions))) + conditions.push(and(eq(specimen.studySubjectId, subjectId), or(...dateConditions))!) } - if (undatedSubjectIds.length > 0) { - subjectDateConditions.push( - and( - inArray(specimen.studySubjectId, undatedSubjectIds), - ...(filters.date_from ? [gte(specimen.collectionDate, filters.date_from)] : []), - ...(filters.date_to ? [lte(specimen.collectionDate, filters.date_to)] : []), - ), - ) - } - - specimenConditions.push(or(...subjectDateConditions) as any) - } else { - // Use global date filters if no per-subject dates - if (filters.date_from) { - specimenConditions.push( - gte(specimen.collectionDate, filters.date_from) - ) - } - - if (filters.date_to) { - specimenConditions.push( - lte(specimen.collectionDate, filters.date_to) - ) + conditions.push(and(inArray(specimen.studySubjectId, undatedSubjectIds), ...globalDateConditions)!) } + return or(...conditions) } - // Get matching specimens - const specimens = await database - .select({ - id: specimen.id, - studySubjectId: specimen.studySubjectId, - controlBatchId: specimen.controlBatchId, - specimenTypeId: specimen.specimenTypeId, - collectionDate: specimen.collectionDate, - created: specimen.created, - }) - .from(specimen) - .where(and(...specimenConditions) as any) + // Listed subjects are queried a chunk at a time, so neither the IN list nor the per-subject + // OR grows with the upload. + const subjectConditions = listsSubjects + ? chunkArray(filteredSubjectIds, SQL_IN_CHUNK_SIZE).map(subjectChunkCondition) + : [and(inArray(specimen.studySubjectId, studySubjectIds), ...globalDateConditions)] + + const specimens: Array<{ + id: number + studySubjectId: number | null + controlBatchId: number | null + specimenTypeId: number + collectionDate: string | null + created: string + }> = [] + for (const subjectCondition of subjectConditions) { + specimens.push( + ...(await database + .select({ + id: specimen.id, + studySubjectId: specimen.studySubjectId, + controlBatchId: specimen.controlBatchId, + specimenTypeId: specimen.specimenTypeId, + collectionDate: specimen.collectionDate, + created: specimen.created, + }) + .from(specimen) + .where(and(...specimenConditions, subjectCondition))), + ) + } const specimenIds = specimens.map(s => s.id) if (specimenIds.length === 0) { diff --git a/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts b/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts index cd8fa06..bdc6e53 100644 --- a/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts +++ b/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts @@ -9,6 +9,7 @@ import { createTestStorageType, createTestSpecimen, createTestSpecimenType, + createTestStudy, } from '../../../__tests__/helpers/factories' import { utcNow } from '../../datetime' import { micronixTube } from '../../../db/schema' @@ -115,4 +116,27 @@ describe('dashboard-stats', () => { expect(data.containers.byStatus['In Use']).toBe(1) expect(data.containers.total).toBe(1) }) + + it('filters by a study with more subjects than SQLite allows as bound parameters', async () => { + const study = await createTestStudy(testDb, { title: 'Big Study', shortCode: 'BIG' }) + const type = await createTestSpecimenType(testDb, { name: 'DBS' }) + sqlite.exec('BEGIN') + const addSubject = sqlite.prepare( + "INSERT INTO study_subject (study_id, name, created, last_updated) VALUES (?, ?, '2024-01-01', '2024-01-01')", + ) + const subjectIds: number[] = [] + for (let i = 0; i < 70_000; i++) { + subjectIds.push(Number(addSubject.run(study.id, `S${i}`).lastInsertRowid)) + } + sqlite.exec('COMMIT') + for (const subjectId of subjectIds.slice(0, 3)) { + await createTestSpecimen(testDb, type.id, { studySubjectId: subjectId }) + } + await createTestSpecimen(testDb, type.id) // in another study + + const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, { study: 'BIG' }) + + expect(data.specimens.total).toBe(3) + expect(data.specimens.byStudy).toEqual({ BIG: 3 }) + }, 60_000) }) diff --git a/packages/api/src/lib/statistics/__tests__/specimen-aggregates.test.ts b/packages/api/src/lib/statistics/__tests__/specimen-aggregates.test.ts index b428bab..87a0f8d 100644 --- a/packages/api/src/lib/statistics/__tests__/specimen-aggregates.test.ts +++ b/packages/api/src/lib/statistics/__tests__/specimen-aggregates.test.ts @@ -26,9 +26,7 @@ describe('specimen-aggregates', () => { }) it('returns empty aggregates for no specimens', async () => { - const result = await computeSpecimenAggregates(testDb, [], { - subjectIds: [], - }) + const result = await computeSpecimenAggregates(testDb, [], {}) expect(result).toEqual({ total: 0, @@ -54,9 +52,7 @@ describe('specimen-aggregates', () => { studySubjectId: subject.id, }) - const result = await computeSpecimenAggregates(testDb, [specimen], { - subjectIds: [], - }) + const result = await computeSpecimenAggregates(testDb, [specimen], {}) expect(result.total).toBe(1) expect(result.bySourceType.subject).toBe(1) diff --git a/packages/api/src/lib/statistics/dashboard-stats.ts b/packages/api/src/lib/statistics/dashboard-stats.ts index d868eb3..a455a4c 100644 --- a/packages/api/src/lib/statistics/dashboard-stats.ts +++ b/packages/api/src/lib/statistics/dashboard-stats.ts @@ -69,8 +69,7 @@ export async function getDashboardStatistics( // Build specimen filter conditions const specimenConditions = [] - // Filter by study - let subjectIds: number[] = [] + // Filter by study, selecting its subjects with a subquery rather than an id list if (studyCode) { const studyRecord = await database .select() @@ -82,25 +81,21 @@ export async function getDashboardStatistics( if (!studyRecord) { return emptyDashboardStatistics() } - const subjects = await database + const studySubjectIds = database .select({ id: studySubject.id }) .from(studySubject) .where(eq(studySubject.studyId, studyRecord.id)) - subjectIds = subjects.map(s => s.id) - if (subjectIds.length > 0) { - if (subjectIds.length === 1) { - specimenConditions.push( - eq(specimen.studySubjectId, subjectIds[0]) - ) - } else { - specimenConditions.push( - inArray(specimen.studySubjectId, subjectIds) - ) - } - } else { + const anySubject = await database + .select({ id: studySubject.id }) + .from(studySubject) + .where(eq(studySubject.studyId, studyRecord.id)) + .limit(1) + .get() + if (!anySubject) { // No subjects in study, return empty results return emptyDashboardStatistics() } + specimenConditions.push(inArray(specimen.studySubjectId, studySubjectIds)) } if (sourceType === 'subject') { @@ -131,8 +126,6 @@ export async function getDashboardStatistics( const specimenIds = filteredSpecimens.map(s => s.id) let specimenAggregates = await computeSpecimenAggregates(database, filteredSpecimens, { - studyCode, - subjectIds, useSpecimenTypeCache: true, }) @@ -298,8 +291,6 @@ export async function getDashboardStatistics( const adjustedSpecimens = filteredSpecimens.filter((s) => filteredSpecimenIds.has(s.id)) specimenAggregates = await computeSpecimenAggregates(database, adjustedSpecimens, { - studyCode, - subjectIds, useSpecimenTypeCache: false, }) } diff --git a/packages/api/src/lib/statistics/specimen-aggregates.ts b/packages/api/src/lib/statistics/specimen-aggregates.ts index d124cb6..fb0e270 100644 --- a/packages/api/src/lib/statistics/specimen-aggregates.ts +++ b/packages/api/src/lib/statistics/specimen-aggregates.ts @@ -14,8 +14,6 @@ export type SpecimenAggregates = { } type ComputeSpecimenAggregatesOptions = { - studyCode?: string - subjectIds: number[] useSpecimenTypeCache?: boolean } @@ -50,7 +48,7 @@ export async function computeSpecimenAggregates( specimens: Array, options: ComputeSpecimenAggregatesOptions, ): Promise { - const { studyCode, subjectIds, useSpecimenTypeCache = false } = options + const { useSpecimenTypeCache = false } = options const bySourceType: Record = {} specimens.forEach((s) => { @@ -87,38 +85,36 @@ export async function computeSpecimenAggregates( }) const byStudy: Record = {} - if (subjectIds.length > 0 || !studyCode) { - const subjectSpecimens = specimens.filter((s) => s.studySubjectId) - const uniqueSubjectIds = [...new Set(subjectSpecimens.map((s) => s.studySubjectId!))] - - if (uniqueSubjectIds.length > 0) { - const subjectChunks = chunkArray(uniqueSubjectIds, SQL_IN_CHUNK_SIZE) - const allSubjects: Array<{ id: number; studyId: number }> = [] - - for (const chunk of subjectChunks) { - const subjects = await database - .select({ - id: studySubject.id, - studyId: studySubject.studyId, - }) - .from(studySubject) - .where(inArray(studySubject.id, chunk)) - allSubjects.push(...subjects) - } - - const studyIds = [...new Set(allSubjects.map((s) => s.studyId))] - if (studyIds.length > 0) { - const studies = await database.select().from(study).where(inArray(study.id, studyIds)) - const studyMap = new Map(studies.map((s) => [s.id, s.shortCode])) - const subjectStudyMap = new Map(allSubjects.map((s) => [s.id, studyMap.get(s.studyId)])) - - subjectSpecimens.forEach((s) => { - const code = subjectStudyMap.get(s.studySubjectId!) - if (code) { - byStudy[code] = (byStudy[code] || 0) + 1 - } + const subjectSpecimens = specimens.filter((s) => s.studySubjectId) + const uniqueSubjectIds = [...new Set(subjectSpecimens.map((s) => s.studySubjectId!))] + + if (uniqueSubjectIds.length > 0) { + const subjectChunks = chunkArray(uniqueSubjectIds, SQL_IN_CHUNK_SIZE) + const allSubjects: Array<{ id: number; studyId: number }> = [] + + for (const chunk of subjectChunks) { + const subjects = await database + .select({ + id: studySubject.id, + studyId: studySubject.studyId, }) - } + .from(studySubject) + .where(inArray(studySubject.id, chunk)) + allSubjects.push(...subjects) + } + + const studyIds = [...new Set(allSubjects.map((s) => s.studyId))] + if (studyIds.length > 0) { + const studies = await database.select().from(study).where(inArray(study.id, studyIds)) + const studyMap = new Map(studies.map((s) => [s.id, s.shortCode])) + const subjectStudyMap = new Map(allSubjects.map((s) => [s.id, studyMap.get(s.studyId)])) + + subjectSpecimens.forEach((s) => { + const code = subjectStudyMap.get(s.studySubjectId!) + if (code) { + byStudy[code] = (byStudy[code] || 0) + 1 + } + }) } } From 764396d38796efd52dc2cc2dcd64bc6a3c9cf7cd Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:04:46 -0700 Subject: [PATCH 51/85] fix(api): require a parent name for ambiguous source sheets in moves. A sheet mapping without fromParentName moved papers from every sheet with that name, in any box or bag. A sheet name alone now resolves only when exactly one sheet has it, on both the source and the target; otherwise the mapping is reported as ambiguous and asks for the parent. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/__tests__/container-move.test.ts | 44 +++++++++++++++++++ packages/api/src/lib/container-move.ts | 17 +++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/api/src/lib/__tests__/container-move.test.ts b/packages/api/src/lib/__tests__/container-move.test.ts index dd4f566..be6fff0 100644 --- a/packages/api/src/lib/__tests__/container-move.test.ts +++ b/packages/api/src/lib/__tests__/container-move.test.ts @@ -594,5 +594,49 @@ describe('container-move', () => { const [moved] = await testDb.select().from(paper).all() expect(moved.sheetId).toBe(sheetA.id) }) + + /** Boxes BA, BB, BC; Sheet-1 in BA and BB, Only-Sheet in BA, Dest in BC; one paper on BA's Sheet-1. */ + async function seedSheets() { + const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) + const location = await createTestLocation(testDb, { name: 'Loc', storageTypeId: String(storageType.id) }) + const [boxA, boxB, boxC] = await Promise.all( + ['BA', 'BB', 'BC'].map((name) => createTestBox(testDb, { name, locationId: location.id })), + ) + const sheetA = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxA.id }) + await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxB.id }) + const onlySheet = await createTestSheet(testDb, { name: 'Only-Sheet', boxId: boxA.id }) + const dest = await createTestSheet(testDb, { name: 'Dest', boxId: boxC.id }) + const container = await createTestStorageContainer(testDb) + await createTestPaper(testDb, { containerId: container.id, sheetId: sheetA.id }) + return { container, sheetA, onlySheet, dest } + } + + it('reports a source sheet name shared by several sheets instead of moving from all of them', async () => { + const { container, sheetA } = await seedSheets() + + const result = await executeMoves(testDb, { + mappings: [{ fromCollectionName: 'Sheet-1', toCollectionName: 'Dest' }], + moves: [{ identifier: { type: 'container_id', containerId: container.id }, targetPosition: '' }], + }) + + expect(result.success).toBe(false) + expect(result.errors?.[0].error).toMatch(/Source sheet "Sheet-1" matches 2 sheets; set fromParentName/) + const [unmoved] = await testDb.select().from(paper).all() + expect(unmoved.sheetId).toBe(sheetA.id) + }) + + it('resolves sheet names without parent names when each matches one sheet', async () => { + const { container, onlySheet, dest } = await seedSheets() + await testDb.update(paper).set({ sheetId: onlySheet.id }).run() + + const result = await executeMoves(testDb, { + mappings: [{ fromCollectionName: 'Only-Sheet', toCollectionName: 'Dest' }], + moves: [{ identifier: { type: 'container_id', containerId: container.id }, targetPosition: '' }], + }) + + expect(result.success).toBe(true) + const [moved] = await testDb.select().from(paper).all() + expect(moved.sheetId).toBe(dest.id) + }) }) }) diff --git a/packages/api/src/lib/container-move.ts b/packages/api/src/lib/container-move.ts index e10bbeb..d307cba 100644 --- a/packages/api/src/lib/container-move.ts +++ b/packages/api/src/lib/container-move.ts @@ -385,18 +385,27 @@ export async function executeMoves(database: Database, request: BatchMoveRequest const mappingErrors: string[] = [] for (const m of mappings) { if (collectionType === 'sheet') { - // Every sheet with the source name (in fromParentName, if given) maps to the one - // target sheet; the target must be unambiguous. + // A sheet name alone resolves only when exactly one sheet has it; otherwise the + // mapping must name the box or bag. Every source sheet in the named parent maps to + // the one target sheet (legacy bags can hold several with the same name). const fromIds = await resolveSheetsByName(database, m.fromCollectionName, m.fromParentName) const toIds = await resolveSheetsByName(database, m.toCollectionName, m.toParentName) if (fromIds.length === 0) mappingErrors.push(`Source collection "${m.fromCollectionName}" not found`) if (toIds.length === 0) mappingErrors.push(`Target collection "${m.toCollectionName}" not found`) + const ambiguousSource = fromIds.length > 1 && m.fromParentName === undefined + if (ambiguousSource) { + mappingErrors.push( + `Source sheet "${m.fromCollectionName}" matches ${fromIds.length} sheets; set fromParentName to the box or bag that holds it`, + ) + } if (toIds.length > 1) { mappingErrors.push( - `Target sheet "${m.toCollectionName}" matches ${toIds.length} sheets; set toParentName to the box or bag that holds it`, + m.toParentName === undefined + ? `Target sheet "${m.toCollectionName}" matches ${toIds.length} sheets; set toParentName to the box or bag that holds it` + : `Target sheet "${m.toCollectionName}" matches ${toIds.length} sheets in "${m.toParentName}"`, ) } - if (fromIds.length > 0 && toIds.length === 1) { + if (fromIds.length > 0 && !ambiguousSource && toIds.length === 1) { for (const fromId of fromIds) mappingMap.set(fromId, toIds[0]) } continue From b6320fd1004cf1c779af41ccd574617faf0696b7 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:04:54 -0700 Subject: [PATCH 52/85] refactor(api): validate and register /specimens/bulk through Specimen registration. /specimens/bulk validated with registration-prepare, which resolved collections through container-write-placement: a barcode that missed fell back to the name, and two papers on one Sheet were rejected. Its write already went through registerSpecimens, so validate passed rows the import then refused, and refused rows the import accepted. The route now resolves each row's Source and hands the rows to registerSpecimens in one call; validate is checkSpecimenRegistration on the same entries. That leaves registration's collections.ts as the only placement resolver, so container-write-placement, registration-prepare, and container-placement-validation are deleted. ADR 0006 points at the new resolver and ADR 0010 moves /specimens/bulk and /subjects/with-specimens into slice 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0006-unified-container-inbound-write.md | 2 + docs/adr/0010-specimen-registration.md | 2 +- .../container-placement-validation.test.ts | 93 ------ .../registration-orchestrator.test.ts | 61 +++- .../src/lib/container-placement-validation.ts | 160 ---------- .../api/src/lib/container-write-placement.ts | 289 ------------------ .../api/src/lib/registration-orchestrator.ts | 250 +++++++-------- packages/api/src/lib/registration-prepare.ts | 218 ------------- packages/api/src/routes/specimens.ts | 14 +- 9 files changed, 195 insertions(+), 894 deletions(-) delete mode 100644 packages/api/src/lib/__tests__/container-placement-validation.test.ts delete mode 100644 packages/api/src/lib/container-placement-validation.ts delete mode 100644 packages/api/src/lib/container-write-placement.ts delete mode 100644 packages/api/src/lib/registration-prepare.ts diff --git a/docs/adr/0006-unified-container-inbound-write.md b/docs/adr/0006-unified-container-inbound-write.md index 2df4ab7..f656f55 100644 --- a/docs/adr/0006-unified-container-inbound-write.md +++ b/docs/adr/0006-unified-container-inbound-write.md @@ -64,6 +64,8 @@ All persistence funnels through `createContainerForSpecimen` (accepting `Contain Placement resolution (sheet find-or-create within box/bag, plate/box resolve) lives in one module — not duplicated across registration, control batch, and derivation CSV. +> **Amended by [ADR 0010](./0010-specimen-registration.md):** that module is `lib/specimen-registration/collections.ts`, and `createContainerForSpecimen` now wraps `registerSpecimens`. It replaced `container-write-placement.ts`, whose lookup fell back from a missing collection barcode to the name; that file is deleted. The derivation CSV and control batch validators still look collections up themselves until their ADR 0010 slices replace them. + ## CSV One contract mapper — `csvRowToContainerWriteInput` — maps flat CSV rows to `ContainerWriteInput`, then Zod validates. diff --git a/docs/adr/0010-specimen-registration.md b/docs/adr/0010-specimen-registration.md index cd48382..edec8b0 100644 --- a/docs/adr/0010-specimen-registration.md +++ b/docs/adr/0010-specimen-registration.md @@ -34,4 +34,4 @@ We put **Specimen registration** (see [CONTEXT.md](../../CONTEXT.md)) behind one ## Consequences - Validate endpoints keep their wire shapes; each route maps module errors to its existing response. -- Migration is in slices, each deleting the code it replaces: bulk combined import; `/specimens/bulk`, `/subjects/with-specimens`, `POST /specimens` and add-container; derivations and the derivation CSV; control batches. +- Migration is in slices, each deleting the code it replaces: bulk combined import, `/subjects/with-specimens`, and `/specimens/bulk`; `POST /specimens` and add-container; derivations and the derivation CSV; control batches. `/subjects/with-specimens` and `/specimens/bulk` moved into the first slice: the first shares bulk combined's code, and the second's validator was the last user of the old placement resolver (ADR 0006). diff --git a/packages/api/src/lib/__tests__/container-placement-validation.test.ts b/packages/api/src/lib/__tests__/container-placement-validation.test.ts deleted file mode 100644 index d23714a..0000000 --- a/packages/api/src/lib/__tests__/container-placement-validation.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { setupTestDatabase, cleanupTestDatabase } from '../../__tests__/helpers/db-setup' -import { - createTestStorageType, - createTestLocation, - createTestMicronixPlate, - createTestStorageContainer, -} from '../../__tests__/helpers/factories' -import { collectContainerPlacementErrors } from '../container-placement-validation' -import { micronixTube } from '../../db/schema' -import type { Database } from '../../db/client' -import { utcNow } from '../datetime' - -describe('container-placement-validation', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) { - cleanupTestDatabase(sqlite) - } - }) - - describe('collectContainerPlacementErrors', () => { - it('detects duplicate positions within the same collection in payload', async () => { - const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) - const loc = await createTestLocation(testDb, { - name: 'Loc', - storageTypeId: String(storageType.id), - canContainCollections: true, - }) - const plate = await createTestMicronixPlate(testDb, { name: 'Plate1', locationId: loc.id }) - const collectionKey = `micronix_plate-${plate.name}` - - const errors = await collectContainerPlacementErrors(testDb, [ - { - containerType: 'micronix_tube', - collectionId: plate.id, - collectionKey, - normalizedPosition: 'A01', - barcode: 'BC1', - }, - { - containerType: 'micronix_tube', - collectionId: plate.id, - collectionKey, - normalizedPosition: 'A01', - barcode: 'BC2', - }, - ]) - - expect(errors.some((e) => e.message.includes('used more than once in your file'))).toBe(true) - }) - - it('detects position already used in database', async () => { - const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) - const loc = await createTestLocation(testDb, { - name: 'Loc', - storageTypeId: String(storageType.id), - canContainCollections: true, - }) - const plate = await createTestMicronixPlate(testDb, { name: 'Plate1', locationId: loc.id }) - const now = utcNow() - const container = await createTestStorageContainer(testDb) - await testDb.insert(micronixTube).values({ - id: container.id, - collectionId: plate.id, - barcode: 'TAKEN', - position: 'A01', - created: now, - lastUpdated: now, - }) - - const errors = await collectContainerPlacementErrors(testDb, [ - { - containerType: 'micronix_tube', - collectionId: plate.id, - collectionKey: `micronix_plate-${plate.name}`, - normalizedPosition: 'A01', - barcode: 'NEW1', - }, - ]) - - expect(errors.some((e) => e.message.includes('already used in this plate'))).toBe(true) - }) - }) -}) diff --git a/packages/api/src/lib/__tests__/registration-orchestrator.test.ts b/packages/api/src/lib/__tests__/registration-orchestrator.test.ts index f5ee25c..b55d066 100644 --- a/packages/api/src/lib/__tests__/registration-orchestrator.test.ts +++ b/packages/api/src/lib/__tests__/registration-orchestrator.test.ts @@ -11,7 +11,7 @@ import { createTestStorageContainer, } from '../../__tests__/helpers/factories' import { setContainerDefaults } from '../settings' -import { validateBulkSpecimenRows } from '../registration-orchestrator' +import { createBulkSpecimenRows, validateBulkSpecimenRows, type BulkSpecimenValidateRow } from '../registration-orchestrator' import { specimenTypeContainerType, containerTypeUnit, micronixTube, staticWell } from '../../db/schema' import type { Database } from '../../db/client' import { utcNow } from '../datetime' @@ -227,4 +227,63 @@ describe('registration-orchestrator', () => { expect(result.errors.some((e) => e.message.includes('Position A01 is already used'))).toBe(true) }) }) + + describe('validate passes exactly when the import succeeds', () => { + let locationId: number + + beforeEach(async () => { + const study = await createTestStudy(testDb, { title: 'Study 1', shortCode: 'ST1' }) + await createTestStudySubject(testDb, { studyId: study.id, name: 'Subj1' }) + const specimenType = await createTestSpecimenType(testDb, { name: 'DNA' }) + const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) + locationId = (await createTestLocation(testDb, { name: 'Loc', storageTypeId: String(storageType.id), canContainCollections: true })).id + const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' }) + const now = utcNow() + for (const containerType of ['micronix_tube', 'paper'] as const) { + await testDb.insert(specimenTypeContainerType).values({ specimenTypeId: specimenType.id, containerType, created: now, lastUpdated: now }) + await testDb.insert(containerTypeUnit).values({ containerType, unitId: unit.id }) + } + await setContainerDefaults(testDb, { + micronix_tube: { totalQuantity: 1, remainingQuantity: 1, defaultUnitSymbol: 'uL' }, + paper: { totalQuantity: 1, remainingQuantity: 1, defaultUnitSymbol: 'uL' }, + }) + await createTestMicronixPlate(testDb, { name: 'P1', barcode: 'P1-BC', locationId }) + }) + + function row(container?: BulkSpecimenValidateRow['container'], collectionDate = '2024-01-15'): BulkSpecimenValidateRow { + return { sourceType: 'subject', studyShortCode: 'ST1', subjectName: 'Subj1', specimenTypeName: 'DNA', collectionDate, container } + } + + function tube(barcode: string, collection: Record): BulkSpecimenValidateRow['container'] { + return { containerType: 'micronix_tube', barcode, collection: { type: 'micronix_plate', ...collection } } + } + + function paperOn(sheetName: string, sublabel: string): BulkSpecimenValidateRow['container'] { + return { + containerType: 'paper', + sublabel, + collection: { type: 'sheet', name: sheetName, parent: { type: 'box', name: 'PaperBox', locationId } }, + } + } + + const fixtures: Array<{ name: string; valid: boolean; rows: () => BulkSpecimenValidateRow[] }> = [ + { name: 'existing plate name with an unknown barcode', valid: false, rows: () => [row(tube('T1', { name: 'P1', barcode: 'UNKNOWN', position: 'A01' }))] }, + { name: 'two papers on one new sheet', valid: true, rows: () => [row(paperOn('Sheet-1', 's1')), row(paperOn('Sheet-1', 's2'), '2024-02-01')] }, + { name: 'one well named by plate name and by plate barcode', valid: false, rows: () => [row(tube('T1', { name: 'P1', position: 'C01' })), row(tube('T2', { barcode: 'P1-BC', position: 'C1' }), '2024-02-01')] }, + { name: 'a new plate given two locations', valid: false, rows: () => [row(tube('T1', { name: 'NEW', locationId, position: 'A01' })), row(tube('T2', { name: 'NEW', locationId: locationId + 1000, position: 'A02' }), '2024-02-01')] }, + ] + + for (const fixture of fixtures) { + it(fixture.name, async () => { + const validation = await validateBulkSpecimenRows(testDb, fixture.rows()) + const created = await createBulkSpecimenRows(testDb, fixture.rows()).then( + (result) => result.success, + () => false, + ) + + expect(validation.valid).toBe(fixture.valid) + expect(created).toBe(validation.valid) + }) + } + }) }) diff --git a/packages/api/src/lib/container-placement-validation.ts b/packages/api/src/lib/container-placement-validation.ts deleted file mode 100644 index b78d4ce..0000000 --- a/packages/api/src/lib/container-placement-validation.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Barcode, well-position, and sheet-name uniqueness checks for containers in one request. - * Shared by bulk specimen validate, bulk-combined validate/import, and POST /subjects/with-specimens. - */ -import type { Database } from '../db/client' -import { micronixTube, cryovialTube } from '../db/schema' -import { eq } from 'drizzle-orm' -import { normalizePosition } from './normalize-position' -import { checkGridPositionOccupancy, occupiedGridPositionMessage } from './container-occupancy' - -export type PlacementContainerType = 'micronix_tube' | 'cryovial_tube' | 'paper' | 'static_well' - -export type ContainerPlacementCheckRow = { - containerType: PlacementContainerType - collectionId: number | null - collectionKey: string - normalizedPosition: string | null - barcode: string | null - boxKey?: string | null - sheetName?: string | null -} - -export type ContainerPlacementError = { - rowIndex: number - message: string -} - -type PlacementContainer = { - containerType: PlacementContainerType - collectionName?: string - collectionBarcode?: string - barcode?: string - position?: string - sheetName?: string - sublabel?: string -} - -export function buildContainerPlacementCheckRow( - container: PlacementContainer, - collectionId: number | null, - collectionKey: string -): ContainerPlacementCheckRow { - const containerType = container.containerType - return { - containerType, - collectionId, - collectionKey, - normalizedPosition: normalizePosition(container.position), - barcode: - containerType === 'paper' - ? container.sublabel?.trim() || null - : container.barcode?.trim() || null, - boxKey: containerType === 'paper' ? collectionKey : null, - sheetName: containerType === 'paper' ? (container.sheetName ?? 'Sheet-1').trim() : null, - } -} - -export async function collectContainerPlacementErrors( - database: Database, - rows: ContainerPlacementCheckRow[] -): Promise { - const errors: ContainerPlacementError[] = [] - const seenBarcodes = new Set() - const seenPositionByCollection = new Map>() - const seenSheetByBox = new Map>() - - for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const row = rows[rowIndex] - const { - containerType, - collectionId, - collectionKey, - normalizedPosition, - barcode, - boxKey, - sheetName, - } = row - - if (containerType === 'micronix_tube' || containerType === 'cryovial_tube') { - if (barcode) { - const existingInDb = - containerType === 'micronix_tube' - ? await database - .select({ id: micronixTube.id }) - .from(micronixTube) - .where(eq(micronixTube.barcode, barcode)) - .get() - : await database - .select({ id: cryovialTube.id }) - .from(cryovialTube) - .where(eq(cryovialTube.barcode, barcode)) - .get() - if (existingInDb) { - errors.push({ - rowIndex, - message: `Barcode '${barcode}' already exists. Use a different barcode.`, - }) - } - if (seenBarcodes.has(barcode)) { - errors.push({ - rowIndex, - message: `Barcode '${barcode}' is used more than once in your file. Each barcode must be unique.`, - }) - } - seenBarcodes.add(barcode) - } - } - - if ( - normalizedPosition && - (containerType === 'micronix_tube' || containerType === 'cryovial_tube' || containerType === 'static_well') - ) { - if (collectionId !== null) { - // Same rule as the write path: tubes and static wells share plate cells. - const collectionKind = containerType === 'cryovial_tube' ? 'cryovial_box' : 'micronix_plate' - const occupancy = await checkGridPositionOccupancy(database, { - collectionKind, - collectionId, - position: normalizedPosition, - }) - if (occupancy.occupied) { - errors.push({ - rowIndex, - message: occupiedGridPositionMessage(normalizedPosition, collectionKind), - }) - } - } - - let positionSet = seenPositionByCollection.get(collectionKey) - if (!positionSet) { - positionSet = new Set() - seenPositionByCollection.set(collectionKey, positionSet) - } - if (positionSet.has(normalizedPosition)) { - errors.push({ - rowIndex, - message: `Position ${normalizedPosition} in this plate/box is used more than once in your file. Each position can only be used once.`, - }) - } - positionSet.add(normalizedPosition) - } - - if (containerType === 'paper' && boxKey && sheetName) { - let sheetSet = seenSheetByBox.get(boxKey) - if (!sheetSet) { - sheetSet = new Set() - seenSheetByBox.set(boxKey, sheetSet) - } - if (sheetSet.has(sheetName)) { - errors.push({ - rowIndex, - message: `Sheet name '${sheetName}' in box is used more than once in your file.`, - }) - } - sheetSet.add(sheetName) - } - } - - return errors -} diff --git a/packages/api/src/lib/container-write-placement.ts b/packages/api/src/lib/container-write-placement.ts deleted file mode 100644 index 8c4c0c9..0000000 --- a/packages/api/src/lib/container-write-placement.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { ContainerWriteInput } from '@sampledb/contract' -import type { Database } from '../db/client' -import type { ExtractTablesWithRelations } from 'drizzle-orm' -import type { SQLiteTransaction } from 'drizzle-orm/sqlite-core' -import type * as schema from '../db/schema' -import { and, eq } from 'drizzle-orm' -import { bag, box, cryovialBox, micronixPlate, sheet } from '../db/schema' -import { resolveCollection, resolveCollectionByBarcode } from './collections/collection-resolve' -import { ValidationError } from './error-handler' - -type DatabaseOrTransaction = - | Database - | SQLiteTransaction<'sync', void, typeof schema, ExtractTablesWithRelations> - -type GridCollection = NonNullable< - Extract['collection'] -> - -export type BulkCombinedContainerInput = ContainerWriteInput & { - unitId?: number - totalQuantity?: number - remainingQuantity?: number -} - -function asDatabase(database: DatabaseOrTransaction): Database { - return database as unknown as Database -} - -function gridCollectionType( - containerType: 'micronix_tube' | 'cryovial_tube' | 'static_well' -): 'micronix_plate' | 'cryovial_box' { - return containerType === 'cryovial_tube' ? 'cryovial_box' : 'micronix_plate' -} - -function gridCollectionMapKey( - collectionType: 'micronix_plate' | 'cryovial_box', - collection: GridCollection -): string { - const identifier = collection.name ?? collection.barcode ?? String(collection.id ?? '') - return `${collectionType}-${identifier}` -} - -async function lookupGridCollectionId( - database: DatabaseOrTransaction, - containerType: 'micronix_tube' | 'cryovial_tube' | 'static_well', - collection: GridCollection, - collectionMap?: Map -): Promise { - const collectionType = gridCollectionType(containerType) - const key = gridCollectionMapKey(collectionType, collection) - if (collectionMap?.has(key)) { - return collectionMap.get(key)! - } - - const db = asDatabase(database) - - if (collection.id != null) { - const existing = - collectionType === 'cryovial_box' - ? await database.select({ id: cryovialBox.id }).from(cryovialBox).where(eq(cryovialBox.id, collection.id)).get() - : await database.select({ id: micronixPlate.id }).from(micronixPlate).where(eq(micronixPlate.id, collection.id)).get() - if (!existing) { - const label = collectionType === 'cryovial_box' ? 'Cryovial box' : 'Micronix plate' - throw new ValidationError(`${label} not found: id ${collection.id}`) - } - collectionMap?.set(key, collection.id) - return collection.id - } - - if (collection.barcode) { - const byBarcode = await resolveCollectionByBarcode(collection.barcode, collectionType, db) - if (byBarcode) { - collectionMap?.set(key, byBarcode) - return byBarcode - } - } - - if (collection.name) { - const existing = await resolveCollection(collection.name, collectionType, db) - if (existing) { - collectionMap?.set(key, existing) - return existing - } - } - - return null -} - -async function lookupParentCollectionId( - database: DatabaseOrTransaction, - parent: { type: 'box' | 'bag'; id?: number; name?: string; locationId?: number }, - collectionMap?: Map -): Promise { - if (parent.id != null) { - if (parent.type === 'box') { - const boxRecord = await database.select({ id: box.id }).from(box).where(eq(box.id, parent.id)).get() - if (!boxRecord) throw new ValidationError(`Box not found: id ${parent.id}`) - return parent.id - } - const bagRecord = await database.select({ id: bag.id }).from(bag).where(eq(bag.id, parent.id)).get() - if (!bagRecord) throw new ValidationError(`Bag not found: id ${parent.id}`) - return parent.id - } - - if (!parent.name) { - throw new ValidationError('box_name or bag_name is required for paper') - } - - const key = `${parent.type}-${parent.name}` - if (collectionMap?.has(key)) { - return collectionMap.get(key)! - } - - if (parent.type === 'box') { - const byName = await database.select({ id: box.id }).from(box).where(eq(box.name, parent.name)).get() - if (byName) { - collectionMap?.set(key, byName.id) - return byName.id - } - } else { - const byName = await database.select({ id: bag.id }).from(bag).where(eq(bag.name, parent.name)).get() - if (byName) { - collectionMap?.set(key, byName.id) - return byName.id - } - } - - return null -} - -/** Lookup-only: collection exists or can be created (locationId present). Does not insert. */ -export async function assertWriteInputPlacementResolvable( - database: DatabaseOrTransaction, - input: ContainerWriteInput, - collectionMap?: Map -): Promise { - switch (input.containerType) { - case 'micronix_tube': - case 'cryovial_tube': - case 'static_well': { - if (!input.collection) { - throw new ValidationError( - input.containerType === 'cryovial_tube' - ? 'Collection is required for cryovial tubes' - : 'Collection is required for micronix tubes' - ) - } - const existing = await lookupGridCollectionId(database, input.containerType, input.collection, collectionMap) - if (existing == null && input.collection.locationId == null) { - throw new ValidationError('Collection not found and no location provided') - } - return - } - case 'paper': { - const collection = input.collection - if (!collection) { - throw new ValidationError('Collection is required for paper containers') - } - if (collection.id != null) { - const sheetRecord = await database - .select({ id: sheet.id }) - .from(sheet) - .where(eq(sheet.id, collection.id)) - .get() - if (!sheetRecord) { - throw new ValidationError(`Sheet not found: id ${collection.id}`) - } - return - } - if (!collection.name) { - throw new ValidationError('sheet_name is required for paper') - } - if (!collection.parent) { - throw new ValidationError('box_name or bag_name is required for paper') - } - const parentId = await lookupParentCollectionId(database, collection.parent, collectionMap) - if (parentId == null && collection.parent.locationId == null) { - throw new ValidationError( - `Collection (${collection.parent.type}) not found: ${collection.parent.name ?? ''}` - ) - } - return - } - default: { - const _exhaustive: never = input - void _exhaustive - throw new ValidationError('Unsupported container type') - } - } -} - -export function toContainerWriteInput(container: BulkCombinedContainerInput): ContainerWriteInput { - const { unitId: _unitId, totalQuantity: _totalQuantity, remainingQuantity: _remainingQuantity, ...write } = - container - return write -} - -export function collectionKeyFromWriteInput(input: ContainerWriteInput): string { - switch (input.containerType) { - case 'paper': { - const parent = input.collection?.parent - if (parent?.type === 'bag') { - const name = parent.name ?? String(parent.id ?? '') - return `bag-${name}` - } - const boxName = parent?.name ?? input.collection?.name ?? String(input.collection?.id ?? '') - return `box-${boxName}` - } - case 'cryovial_tube': { - const c = input.collection - const identifier = c?.name ?? c?.barcode ?? String(c?.id ?? '') - return `cryovial_box-${identifier}` - } - case 'micronix_tube': - case 'static_well': { - const c = input.collection - const identifier = c?.name ?? c?.barcode ?? String(c?.id ?? '') - return `micronix_plate-${identifier}` - } - default: - return 'unknown' - } -} - -export async function lookupWriteInputCollectionId( - database: DatabaseOrTransaction, - input: ContainerWriteInput, - collectionMap?: Map -): Promise<{ collectionKey: string; collectionId: number | null }> { - const collectionKey = collectionKeyFromWriteInput(input) - if (collectionMap?.has(collectionKey)) { - return { collectionKey, collectionId: collectionMap.get(collectionKey)! } - } - - const dbForResolve = asDatabase(database) - - if (input.containerType === 'paper') { - return { collectionKey, collectionId: null } - } - - const collection = input.collection - if (!collection) { - return { collectionKey, collectionId: null } - } - - if (collection.id != null) { - return { collectionKey, collectionId: collection.id } - } - - if (input.containerType === 'cryovial_tube') { - if (collection.barcode) { - const id = await resolveCollectionByBarcode(collection.barcode, 'cryovial_box', dbForResolve) - return { collectionKey, collectionId: id } - } - if (collection.name) { - const id = await resolveCollection(collection.name, 'cryovial_box', dbForResolve) - return { collectionKey, collectionId: id } - } - } else { - if (collection.barcode) { - const id = await resolveCollectionByBarcode(collection.barcode, 'micronix_plate', dbForResolve) - return { collectionKey, collectionId: id } - } - if (collection.name) { - const id = await resolveCollection(collection.name, 'micronix_plate', dbForResolve) - return { collectionKey, collectionId: id } - } - } - - return { collectionKey, collectionId: null } -} - -export function placementContainerFromWriteInput(input: ContainerWriteInput) { - if (input.containerType === 'paper') { - return { - containerType: 'paper' as const, - collectionName: input.collection?.parent?.name, - sublabel: input.sublabel, - sheetName: input.collection?.name, - } - } - return { - containerType: input.containerType, - collectionName: input.collection?.name, - collectionBarcode: input.collection?.barcode, - barcode: 'barcode' in input ? input.barcode : undefined, - position: input.collection?.position, - } -} diff --git a/packages/api/src/lib/registration-orchestrator.ts b/packages/api/src/lib/registration-orchestrator.ts index f4b5fe0..8d242d8 100644 --- a/packages/api/src/lib/registration-orchestrator.ts +++ b/packages/api/src/lib/registration-orchestrator.ts @@ -1,153 +1,157 @@ /** - * Registration orchestrator — validate/create entry points for bulk specimen registration. - * Prepare logic lives in registration-prepare.ts. + * POST /specimens/bulk and its validate route. Each row's Source is resolved here; Specimens and + * Containers go through Specimen registration (ADR 0010), and validation is the same + * registration, rolled back. */ import type { Database } from '../db/client' -import { specimen } from '../db/schema' -import { withWriteTransaction } from '../db/write-transaction' -import { createContainerForSpecimen, pickContainerQuantity } from './container-creation' -import { findExistingStudySpecimen, findExistingControlSpecimen } from './specimen-helpers' -import { utcNow } from './datetime' +import type { specimen } from '../db/schema' +import { ConflictError } from './error-handler' +import { validateSpecimenData } from './validation' import { - toContainerWriteInput, - type BulkCombinedContainerInput, -} from './container-write-placement' -import { - bulkSpecimenPayloadKey, - prepareRegistrationBatchForSpecimens, - type BulkSpecimenValidateRow, - type BulkSpecimenValidateError, - type BulkSpecimenValidateResult, - type BulkSpecimenCreateResult, -} from './registration-prepare' + checkSpecimenRegistration, + registerSpecimens, + SpecimenRegistrationError, + type RegistrationContainer, + type RegistrationEntry, + type RegistrationError, +} from './specimen-registration' + +export type BulkSpecimenContainerInput = Omit & { + containerType?: RegistrationContainer['containerType'] +} + +export type BulkSpecimenValidateRow = { + sourceType: 'subject' | 'control' + sourceId?: number + studyShortCode?: string + subjectName?: string + specimenTypeName: string + collectionDate?: string + container?: BulkSpecimenContainerInput +} + +export type BulkSpecimenValidateError = { + index: number + message: string +} + +export type BulkSpecimenValidateResult = { + valid: boolean + errors: BulkSpecimenValidateError[] +} + +export type BulkSpecimenCreateResult = { + specimens: Array + created: number + containersCreated: number +} + +/** A row's container, if it names a container type; rows without one register only the Specimen. */ +function rowContainers(row: BulkSpecimenValidateRow): RegistrationContainer[] { + const container = row.container + if (!container?.containerType) return [] + return [container as RegistrationContainer] +} + +/** Resolve each row's Source and specimen type into a registration entry, or a row error. */ +async function toRegistrationEntries( + database: Database, + rows: BulkSpecimenValidateRow[], +): Promise<{ entries: RegistrationEntry[]; rowOfEntry: number[]; errors: BulkSpecimenValidateError[] }> { + const entries: RegistrationEntry[] = [] + const rowOfEntry: number[] = [] + const errors: BulkSpecimenValidateError[] = [] + + for (let index = 0; index < rows.length; index++) { + const row = rows[index] + const validation = await validateSpecimenData( + { + sourceType: row.sourceType, + sourceId: row.sourceId, + studyShortCode: row.studyShortCode, + subjectName: row.subjectName, + specimenTypeName: row.specimenTypeName, + collectionDate: row.collectionDate, + }, + database, + ) + const resolved = validation.resolved + if (!validation.valid || !resolved) { + errors.push({ index, message: validation.error ?? 'Invalid specimen data' }) + continue + } + const source = + resolved.studySubjectId != null + ? { subjectId: resolved.studySubjectId } + : resolved.controlBatchId != null + ? { controlBatchId: resolved.controlBatchId } + : null + if (!source) { + errors.push({ index, message: 'Invalid specimen data' }) + continue + } + entries.push({ + source, + specimenTypeId: resolved.specimenTypeId, + collectionDate: resolved.collectionDate, + containers: rowContainers(row), + }) + rowOfEntry.push(index) + } -export type { - BulkSpecimenContainerInput, - BulkSpecimenValidateRow, - BulkSpecimenValidateError, - BulkSpecimenValidateResult, - BulkSpecimenCreateResult, - PreparedBulkSpecimenRow, - SpecimenContainerRegistrationResult, -} from './registration-prepare' + return { entries, rowOfEntry, errors } +} -export { - bulkSpecimenPayloadKey, - prepareRegistrationBatchForSpecimens, - validateSpecimenContainerRegistration, -} from './registration-prepare' +function rowErrors(errors: RegistrationError[], rowOfEntry: number[]): BulkSpecimenValidateError[] { + return errors.map((error) => ({ index: rowOfEntry[error.entryIndex], message: error.message })) +} /** - * Validate bulk specimen rows without creating records. - * Used by POST /specimens/bulk/validate. + * Validate bulk specimen rows without creating records: the registration POST /specimens/bulk + * would run, rolled back. Used by POST /specimens/bulk/validate. */ export async function validateBulkSpecimenRows( database: Database, - rows: BulkSpecimenValidateRow[] + rows: BulkSpecimenValidateRow[], ): Promise { - const result = await prepareRegistrationBatchForSpecimens(database, rows) - return { - valid: result.valid, - errors: result.valid ? [] : result.errors, - } + const { entries, rowOfEntry, errors } = await toRegistrationEntries(database, rows) + const check = await checkSpecimenRegistration(database, entries) + errors.push(...rowErrors(check.errors, rowOfEntry)) + errors.sort((a, b) => a.index - b.index) + return { valid: errors.length === 0, errors } } /** - * Create bulk specimen rows after the same validation as validateBulkSpecimenRows. - * Used by POST /specimens/bulk. + * Register bulk specimen rows, all or nothing. Used by POST /specimens/bulk. + * Throws ConflictError when a write hits a database constraint. */ export async function createBulkSpecimenRows( database: Database, rows: BulkSpecimenValidateRow[], - userId?: number + userId?: number, ): Promise< | { success: true; result: BulkSpecimenCreateResult } | { success: false; errors: BulkSpecimenValidateError[] } > { - const prep = await prepareRegistrationBatchForSpecimens(database, rows) - if (!prep.valid) { - return { success: false, errors: prep.errors } + const { entries, rowOfEntry, errors } = await toRegistrationEntries(database, rows) + if (errors.length > 0) { + return { success: false, errors } } - const { prepared } = prep - const now = utcNow() - - const orderedPayloadKeys = prepared.map(({ row, resolved }) => bulkSpecimenPayloadKey(row, resolved)) - const payloadKeyToFirstIndex = new Map() - for (let idx = 0; idx < orderedPayloadKeys.length; idx++) { - const key = orderedPayloadKeys[idx] - if (!payloadKeyToFirstIndex.has(key)) payloadKeyToFirstIndex.set(key, idx) - } - const uniqueSpecimenOrder: number[] = [] - const indexToUniqueIndex: number[] = [] - for (let idx = 0; idx < orderedPayloadKeys.length; idx++) { - const first = payloadKeyToFirstIndex.get(orderedPayloadKeys[idx]) ?? idx - if (first === idx) uniqueSpecimenOrder.push(idx) - indexToUniqueIndex.push(uniqueSpecimenOrder.indexOf(first)) - } - - // Specimens and their containers commit together: a container failure must not leave bare specimens. - return withWriteTransaction(database, async (dbTx) => { - const specimenRecordsByUniqueIndex: Array = [] - let newCount = 0 - for (const uniqueIdx of uniqueSpecimenOrder) { - const { row, resolved } = prepared[uniqueIdx] - const studySubjectId = resolved.studySubjectId - const existing = - row.sourceType === 'subject' && studySubjectId != null - ? findExistingStudySpecimen(dbTx, studySubjectId, resolved.specimenTypeId, row.collectionDate) - : row.sourceType === 'control' && resolved.controlBatchId != null - ? findExistingControlSpecimen(dbTx, resolved.controlBatchId, resolved.specimenTypeId, row.collectionDate) - : null - if (existing) { - specimenRecordsByUniqueIndex.push(existing) - } else { - const insertResult = dbTx - .insert(specimen) - .values({ - studySubjectId: resolved.studySubjectId, - controlBatchId: resolved.controlBatchId, - specimenTypeId: resolved.specimenTypeId, - collectionDate: row.collectionDate ?? null, - created: now, - lastUpdated: now, - createdBy: userId, - updatedBy: userId, - }) - .returning() - .get() - const inserted = Array.isArray(insertResult) ? insertResult[0] : insertResult - if (!inserted) throw new Error('Insert did not return specimen row') - specimenRecordsByUniqueIndex.push(inserted as typeof specimen.$inferSelect) - newCount += 1 - } - } - - const specimensOut = indexToUniqueIndex.map((ui) => specimenRecordsByUniqueIndex[ui]) - let containersCount = 0 - for (let i = 0; i < prepared.length; i++) { - const { row, index } = prepared[i] - const specimenRecord = specimensOut[i] - if (!row.container?.containerType) continue - const container = row.container as BulkCombinedContainerInput - const writeInput = toContainerWriteInput(container) - const containerResult = await createContainerForSpecimen(specimenRecord.id, writeInput, dbTx, { - userId, - quantity: pickContainerQuantity(container), - }) - if (!containerResult.success || !containerResult.containerId) { - throw new Error(containerResult.error ?? `Row ${index}: failed to create container`) - } - containersCount += 1 - } - + try { + const result = await registerSpecimens(database, entries, { userId }) return { - success: true as const, + success: true, result: { - specimens: specimensOut, - created: newCount, - containersCreated: containersCount, + specimens: result.entries.map((entry) => entry.specimen), + created: result.entries.filter((entry) => !entry.reused).length, + containersCreated: result.entries.reduce((sum, entry) => sum + entry.containerIds.length, 0), }, } - }) + } catch (error) { + if (!(error instanceof SpecimenRegistrationError)) throw error + const mapped = rowErrors(error.errors, rowOfEntry) + if (error.status === 409) throw new ConflictError(error.message, { errors: mapped }) + return { success: false, errors: mapped } + } } diff --git a/packages/api/src/lib/registration-prepare.ts b/packages/api/src/lib/registration-prepare.ts deleted file mode 100644 index a19cfcb..0000000 --- a/packages/api/src/lib/registration-prepare.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Registration batch prepare — provenance resolution and container placement checks - * for bulk specimen validate/create. - */ -import type { Database } from '../db/client' -import { specimen } from '../db/schema' -import { validateSpecimenData, validateContainerTypeForSpecimenType } from './validation' -import { validateContainerWriteFields } from './specimen-registration' -import { - buildContainerPlacementCheckRow, - collectContainerPlacementErrors, - type ContainerPlacementCheckRow, -} from './container-placement-validation' -import { - assertLocationCanContainCollections, - CollectionLocationNotAllowedError, - CollectionLocationNotFoundError, - LOCATION_CANNOT_CONTAIN_COLLECTIONS, -} from './collections/collection-lifecycle' -import { - assertWriteInputPlacementResolvable, - lookupWriteInputCollectionId, - placementContainerFromWriteInput, - toContainerWriteInput, - type BulkCombinedContainerInput, -} from './container-write-placement' - -export type BulkSpecimenContainerInput = Omit & { - containerType?: BulkCombinedContainerInput['containerType'] -} - -export type BulkSpecimenValidateRow = { - sourceType: 'subject' | 'control' - sourceId?: number - studyShortCode?: string - subjectName?: string - specimenTypeName: string - collectionDate?: string - container?: BulkSpecimenContainerInput -} - -export type BulkSpecimenValidateError = { - index: number - message: string -} - -export type BulkSpecimenValidateResult = { - valid: boolean - errors: BulkSpecimenValidateError[] -} - -export type BulkSpecimenCreateResult = { - specimens: Array - created: number - containersCreated: number -} - -type ResolvedSpecimen = NonNullable>['resolved']> - -export type PreparedBulkSpecimenRow = { - index: number - row: BulkSpecimenValidateRow - resolved: ResolvedSpecimen -} - -export type SpecimenContainerRegistrationResult = - | { - valid: true - collectionId: number | null - collectionKey: string - placementRow: ContainerPlacementCheckRow - } - | { valid: false; error: string } - -export async function validateSpecimenContainerRegistration( - database: Database, - specimenTypeId: number, - container: BulkCombinedContainerInput -): Promise { - const writeInput = toContainerWriteInput(container) - const containerType = writeInput.containerType - const containerTypeValidation = await validateContainerTypeForSpecimenType( - database, - specimenTypeId, - containerType - ) - if (!containerTypeValidation.valid) { - return { - valid: false, - error: containerTypeValidation.error || 'Invalid container type for specimen type', - } - } - - const containerValidation = validateContainerWriteFields(writeInput) - if (!containerValidation.valid) { - return { valid: false, error: containerValidation.error || 'Invalid container data' } - } - - try { - await assertWriteInputPlacementResolvable(database, writeInput) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Invalid container placement' - return { valid: false, error: message } - } - - const collection = writeInput.collection - const locationId = - (collection && 'locationId' in collection ? collection.locationId : undefined) ?? - (collection && 'parent' in collection ? collection.parent?.locationId : undefined) - if (locationId != null) { - try { - assertLocationCanContainCollections(database, locationId) - } catch (error) { - if (error instanceof CollectionLocationNotFoundError) { - return { valid: false, error: 'Location not found' } - } - if (error instanceof CollectionLocationNotAllowedError) { - return { valid: false, error: LOCATION_CANNOT_CONTAIN_COLLECTIONS } - } - throw error - } - } - - const collectionResolution = await lookupWriteInputCollectionId(database, writeInput) - return { - valid: true, - collectionId: collectionResolution.collectionId, - collectionKey: collectionResolution.collectionKey, - placementRow: buildContainerPlacementCheckRow( - placementContainerFromWriteInput(writeInput), - collectionResolution.collectionId, - collectionResolution.collectionKey - ), - } -} - -/** Stable dedupe key for bulk specimen rows (subject/control + type + date). */ -export function bulkSpecimenPayloadKey(row: BulkSpecimenValidateRow, resolved: ResolvedSpecimen): string { - return row.sourceType === 'subject' - ? `s:${String(row.studyShortCode ?? '')}:${String(row.subjectName ?? '')}:${String(row.specimenTypeName)}:${String(row.collectionDate ?? '')}` - : `c:${String(resolved.controlBatchId ?? '')}:${String(resolved.specimenTypeId)}:${String(row.collectionDate ?? '')}` -} - -/** - * Resolve provenance and container placement for flat bulk specimen rows. - */ -export async function prepareRegistrationBatchForSpecimens( - database: Database, - rows: BulkSpecimenValidateRow[] -): Promise< - | { valid: true; prepared: PreparedBulkSpecimenRow[] } - | { valid: false; errors: BulkSpecimenValidateError[] } -> { - const errors: BulkSpecimenValidateError[] = [] - const prepared: PreparedBulkSpecimenRow[] = [] - const placementRows: ContainerPlacementCheckRow[] = [] - const placementIndexToRowIndex: number[] = [] - - for (let index = 0; index < rows.length; index++) { - const row = rows[index] - try { - const validation = await validateSpecimenData( - { - sourceType: row.sourceType, - sourceId: row.sourceId, - studyShortCode: row.studyShortCode, - subjectName: row.subjectName, - specimenTypeName: row.specimenTypeName, - collectionDate: row.collectionDate, - }, - database - ) - if (!validation.valid || !validation.resolved) { - errors.push({ index, message: validation.error || 'Invalid specimen data' }) - continue - } - - // Carry the normalized date so dedup keys and inserts use the same form. - prepared.push({ index, row: { ...row, collectionDate: validation.resolved.collectionDate }, resolved: validation.resolved }) - - if (!row.container?.containerType) { - continue - } - - const containerRegistration = await validateSpecimenContainerRegistration( - database, - validation.resolved.specimenTypeId, - row.container as BulkCombinedContainerInput - ) - if (!containerRegistration.valid) { - errors.push({ index, message: containerRegistration.error }) - continue - } - - placementRows.push(containerRegistration.placementRow) - placementIndexToRowIndex.push(index) - } catch (error: unknown) { - errors.push({ - index, - message: error instanceof Error ? error.message : 'Validation failed', - }) - } - } - - const placementErrors = await collectContainerPlacementErrors(database, placementRows) - for (const placementError of placementErrors) { - const originalIndex = placementIndexToRowIndex.at(placementError.rowIndex) - if (originalIndex !== undefined) { - errors.push({ index: originalIndex, message: placementError.message }) - } - } - - if (errors.length > 0) { - return { valid: false, errors } - } - - return { valid: true, prepared } -} diff --git a/packages/api/src/routes/specimens.ts b/packages/api/src/routes/specimens.ts index f5a8744..ec41142 100644 --- a/packages/api/src/routes/specimens.ts +++ b/packages/api/src/routes/specimens.ts @@ -6,10 +6,7 @@ import { z } from 'zod' import { listSpecimens } from '../lib/specimens/specimen-read' import { validateSpecimenData } from '../lib/validation' import { createContainerForSpecimen, pickContainerQuantity } from '../lib/container-creation' -import { - toContainerWriteInput, - type BulkCombinedContainerInput, -} from '../lib/container-write-placement' +import type { RegistrationContainer } from '../lib/specimen-registration' import { ConflictError, handleRouteError, NotFoundError, ValidationError } from '../lib/error-handler' import { containerSchema, containerSchemaWithLocation } from '../lib/schemas' import { createAuthMiddleware, createMemberMiddleware } from '../middleware/auth' @@ -112,10 +109,10 @@ specimens.post('/:id/containers', memberMiddleware, async (c) => { if (parsed == null) { throw new ValidationError('Container is required') } - const container = parsed as BulkCombinedContainerInput + const container = parsed as RegistrationContainer const user = c.get('user') - const result = await createContainerForSpecimen(id, toContainerWriteInput(container), dbInstance, { + const result = await createContainerForSpecimen(id, container, dbInstance, { userId: user?.id, quantity: pickContainerQuantity(container), }) @@ -191,9 +188,8 @@ specimens.post('/', memberMiddleware, async (c) => { if (data.container) { try { - const container = data.container as BulkCombinedContainerInput - const writeInput = toContainerWriteInput(container) - const result = await createContainerForSpecimen(newSpecimen.id, writeInput, dbInstance, { + const container = data.container as RegistrationContainer + const result = await createContainerForSpecimen(newSpecimen.id, container, dbInstance, { userId: user?.id, quantity: pickContainerQuantity(container), }) From 3a511d6da95e0cc645c18f6b5a1b275221a48912 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:05:25 -0700 Subject: [PATCH 53/85] fix(web): read chart colors for the theme being rendered and drop the exhaustive-deps disables. The getters read CSS variables from the document root, which ThemeProvider updates in a layout effect after children render, so a memo keyed on the theme kept the old theme's colors. They now take the theme and read it from a probe element. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/components/StatChart.tsx | 3 +- packages/web/src/components/StudyTimeline.tsx | 3 +- .../components/dashboard/SystemInsights.tsx | 3 +- .../src/lib/__tests__/chart-colors.test.ts | 33 ++++++++++++ packages/web/src/lib/chart-colors.ts | 53 +++++++++++-------- packages/web/src/styles/tokens.css | 3 +- 6 files changed, 70 insertions(+), 28 deletions(-) create mode 100644 packages/web/src/lib/__tests__/chart-colors.test.ts diff --git a/packages/web/src/components/StatChart.tsx b/packages/web/src/components/StatChart.tsx index d37daeb..c62c617 100644 --- a/packages/web/src/components/StatChart.tsx +++ b/packages/web/src/components/StatChart.tsx @@ -49,8 +49,7 @@ export default function StatChart({ cardClassName = 'bg-app-card rounded-lg shadow p-6', }: StatChartProps) { const { theme } = useTheme() - // eslint-disable-next-line react-hooks/exhaustive-deps -- reads CSS variables; theme changes them - const tooltipStyles = useMemo(() => getAppTooltipStyles(), [theme]) + const tooltipStyles = useMemo(() => getAppTooltipStyles(theme), [theme]) // Calculate total for percentage calculations const total = data.reduce((sum, entry) => sum + (entry[dataKey] as number), 0) diff --git a/packages/web/src/components/StudyTimeline.tsx b/packages/web/src/components/StudyTimeline.tsx index c32f5cc..27e7b75 100644 --- a/packages/web/src/components/StudyTimeline.tsx +++ b/packages/web/src/components/StudyTimeline.tsx @@ -219,7 +219,7 @@ export default function StudyTimeline({ data }: StudyTimelineProps) { ctx.save() ctx.translate(margin.left, margin.top) - const axisColors = getAppAxisColors() + const axisColors = getAppAxisColors(theme) // Create scales const xScale = scaleLinear() @@ -353,7 +353,6 @@ export default function StudyTimeline({ data }: StudyTimelineProps) { .style('font-size', '14px') .style('text-anchor', 'middle') .text('Subject') - // eslint-disable-next-line react-hooks/exhaustive-deps -- reads CSS variables; theme changes them }, [theme, allPoints, dateRange, filteredSubjects, yAxisLabels, seriesData]) // Handle mouse move for tooltip diff --git a/packages/web/src/components/dashboard/SystemInsights.tsx b/packages/web/src/components/dashboard/SystemInsights.tsx index e53ae96..e4025b1 100644 --- a/packages/web/src/components/dashboard/SystemInsights.tsx +++ b/packages/web/src/components/dashboard/SystemInsights.tsx @@ -13,8 +13,7 @@ interface SystemInsightsProps { export default function SystemInsights({ data, loading }: SystemInsightsProps) { const { theme } = useTheme() - // eslint-disable-next-line react-hooks/exhaustive-deps -- reads CSS variables; theme changes them - const chartColors = useMemo(() => getAppChartColors(), [theme]) + const chartColors = useMemo(() => getAppChartColors(theme), [theme]) if (loading || !data) { return ( diff --git a/packages/web/src/lib/__tests__/chart-colors.test.ts b/packages/web/src/lib/__tests__/chart-colors.test.ts new file mode 100644 index 0000000..9c13c4d --- /dev/null +++ b/packages/web/src/lib/__tests__/chart-colors.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { getAppAxisColors, getAppChartColors, getAppTooltipStyles } from '../chart-colors' + +describe('chart colors', () => { + let style: HTMLStyleElement | null = null + + afterEach(() => { + style?.remove() + style = null + delete document.documentElement.dataset.theme + }) + + it('reads the tokens of the requested theme, not the theme the document shows yet', () => { + style = document.createElement('style') + style.textContent = ` + :root, [data-theme="light"] { --app-text: 10 20 30; --app-border: 40 50 60; --app-card: 1 1 1; --app-chart-1: 7 8 9; } + [data-theme="dark"] { --app-text: 200 210 220; --app-border: 90 90 90; --app-card: 2 2 2; --app-chart-1: 4 5 6; } + ` + document.head.appendChild(style) + document.documentElement.dataset.theme = 'light' + + expect(getAppAxisColors('dark')).toEqual({ text: 'rgb(200, 210, 220)', border: 'rgb(90, 90, 90)' }) + expect(getAppChartColors('dark')[0]).toBe('rgb(4, 5, 6)') + expect(getAppTooltipStyles('dark').contentStyle.backgroundColor).toBe('rgb(2 2 2)') + expect(getAppAxisColors('light').text).toBe('rgb(10, 20, 30)') + }) + + it('leaves no probe element behind', () => { + const before = document.body.childElementCount + getAppChartColors('light') + expect(document.body.childElementCount).toBe(before) + }) +}) diff --git a/packages/web/src/lib/chart-colors.ts b/packages/web/src/lib/chart-colors.ts index 2614f17..62aeaec 100644 --- a/packages/web/src/lib/chart-colors.ts +++ b/packages/web/src/lib/chart-colors.ts @@ -1,9 +1,26 @@ import type { CSSProperties } from 'react' +import type { Theme } from '../contexts/theme-types' /** - * Read app chart palette from CSS variables (--app-chart-1 … --app-chart-8). - * Values are theme-aware (light/dark) so charts adapt automatically. + * Read token values for `theme` from a hidden probe element carrying that theme, not from the + * document root: a component rendering for a newly chosen theme runs before ThemeProvider applies + * it to the root. Returns null outside a browser. */ +function readThemeTokens(theme: Theme, names: readonly string[]): string[] | null { + if (typeof document === 'undefined') return null + const probe = document.createElement('div') + probe.dataset.theme = theme + probe.hidden = true + document.body.appendChild(probe) + try { + const style = getComputedStyle(probe) + return names.map((name) => style.getPropertyValue(name).trim()) + } finally { + probe.remove() + } +} + +/** App chart palette tokens (--app-chart-1 … --app-chart-8); each theme sets its own values. */ const CHART_TOKEN_NAMES = [ '--app-chart-1', '--app-chart-2', @@ -24,8 +41,9 @@ function parseTokenRgb(value: string): string { return `rgb(${r}, ${g}, ${b})` } -export function getAppChartColors(): string[] { - if (typeof document === 'undefined') { +export function getAppChartColors(theme: Theme): string[] { + const tokens = readThemeTokens(theme, CHART_TOKEN_NAMES) + if (!tokens) { return [ 'rgb(20, 184, 166)', 'rgb(13, 148, 136)', @@ -37,10 +55,8 @@ export function getAppChartColors(): string[] { 'rgb(71, 85, 105)', ] } - const root = document.documentElement - const style = getComputedStyle(root) - return CHART_TOKEN_NAMES.map((name) => { - const value = style.getPropertyValue(name).trim() + return CHART_TOKEN_NAMES.map((_, i) => { + const value = tokens[i] return value ? parseTokenRgb(value) : 'rgb(100, 116, 139)' }) } @@ -64,14 +80,12 @@ export interface AppAxisColors { } /** Theme-aware colors for chart axes, grid lines, and labels (e.g. StudyTimeline). */ -export function getAppAxisColors(): AppAxisColors { - if (typeof document === 'undefined') { +export function getAppAxisColors(theme: Theme): AppAxisColors { + const tokens = readThemeTokens(theme, [AXIS_TOKEN_NAMES.text, AXIS_TOKEN_NAMES.border]) + if (!tokens) { return { text: 'rgb(30, 41, 59)', border: 'rgb(226, 232, 240)' } } - const root = document.documentElement - const style = getComputedStyle(root) - const text = style.getPropertyValue(AXIS_TOKEN_NAMES.text).trim() - const border = style.getPropertyValue(AXIS_TOKEN_NAMES.border).trim() + const [text, border] = tokens return { text: text ? parseTokenRgb(text) : 'rgb(30, 41, 59)', border: border ? parseTokenRgb(border) : 'rgb(226, 232, 240)', @@ -79,8 +93,9 @@ export function getAppAxisColors(): AppAxisColors { } /** Theme-aware styles for Recharts (and other) chart tooltips. */ -export function getAppTooltipStyles(): AppTooltipStyles { - if (typeof document === 'undefined') { +export function getAppTooltipStyles(theme: Theme): AppTooltipStyles { + const tokens = readThemeTokens(theme, [TOOLTIP_TOKEN_NAMES.card, TOOLTIP_TOKEN_NAMES.text, TOOLTIP_TOKEN_NAMES.border]) + if (!tokens) { return { contentStyle: { margin: 0, @@ -94,11 +109,7 @@ export function getAppTooltipStyles(): AppTooltipStyles { labelStyle: { color: 'rgb(30, 41, 59)' }, } } - const root = document.documentElement - const style = getComputedStyle(root) - const card = style.getPropertyValue(TOOLTIP_TOKEN_NAMES.card).trim() - const text = style.getPropertyValue(TOOLTIP_TOKEN_NAMES.text).trim() - const border = style.getPropertyValue(TOOLTIP_TOKEN_NAMES.border).trim() + const [card, text, border] = tokens const bg = card ? `rgb(${card})` : 'rgb(255, 255, 255)' const textColor = text ? `rgb(${text})` : 'rgb(30, 41, 59)' const borderColor = border ? `rgb(${border})` : 'rgb(226, 232, 240)' diff --git a/packages/web/src/styles/tokens.css b/packages/web/src/styles/tokens.css index 2792f10..f87798f 100644 --- a/packages/web/src/styles/tokens.css +++ b/packages/web/src/styles/tokens.css @@ -5,7 +5,8 @@ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700&display=swap'); -:root { +:root, +[data-theme="light"] { /* Surfaces */ --app-bg: 248 250 252; --app-surface: 241 245 249; From bb9bbe54c13ae7097fe57227e06347873e390292 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:05:28 -0700 Subject: [PATCH 54/85] refactor(registration): drop collectionsToCreate from the check result. checkSpecimenRegistration returned the Collections a registration would create, which no caller read. It now returns the errors alone. checkSpecimenRegistration and the controlBatchId source stay: the /specimens/bulk validate route calls the first and passes control rows through the second. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/src/lib/registration-orchestrator.ts | 3 +-- .../__tests__/specimen-registration.test.ts | 18 ++++-------------- .../api/src/lib/specimen-registration/index.ts | 15 ++++++--------- .../api/src/lib/specimen-registration/types.ts | 5 ----- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/packages/api/src/lib/registration-orchestrator.ts b/packages/api/src/lib/registration-orchestrator.ts index 8d242d8..90b3635 100644 --- a/packages/api/src/lib/registration-orchestrator.ts +++ b/packages/api/src/lib/registration-orchestrator.ts @@ -115,8 +115,7 @@ export async function validateBulkSpecimenRows( rows: BulkSpecimenValidateRow[], ): Promise { const { entries, rowOfEntry, errors } = await toRegistrationEntries(database, rows) - const check = await checkSpecimenRegistration(database, entries) - errors.push(...rowErrors(check.errors, rowOfEntry)) + errors.push(...rowErrors(await checkSpecimenRegistration(database, entries), rowOfEntry)) errors.sort((a, b) => a.index - b.index) return { valid: errors.length === 0, errors } } diff --git a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts index 273e508..0947292 100644 --- a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts +++ b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts @@ -303,28 +303,18 @@ describe('Specimen registration', () => { }) describe('checkSpecimenRegistration', () => { - it('writes nothing and lists the collections the registration would create', async () => { + it('writes nothing and reports no errors for a registration that would succeed', async () => { const entries = [entry([tube('T1', { name: 'NEW', locationId, position: 'A01' }), paperOn('Sheet-1')])] - const check = await checkSpecimenRegistration(db, entries) - - expect(check).toEqual({ - errors: [], - collectionsToCreate: [ - { type: 'micronix_plate', name: 'NEW' }, - { type: 'box', name: 'PaperBox' }, - { type: 'sheet', name: 'Sheet-1' }, - ], - }) + expect(await checkSpecimenRegistration(db, entries)).toEqual([]) expect([await count(specimen), await count(micronixPlate), await count(sheet)]).toEqual([0, 1, 0]) await expect(registerSpecimens(db, entries)).resolves.toBeDefined() }) it('returns the errors registerSpecimens would throw', async () => { - const check = await checkSpecimenRegistration(db, [entry([tube('T1', { name: 'P1', barcode: 'UNKNOWN', position: 'A01' })])]) + const errors = await checkSpecimenRegistration(db, [entry([tube('T1', { name: 'P1', barcode: 'UNKNOWN', position: 'A01' })])]) - expect(check.errors).toHaveLength(1) - expect(check.collectionsToCreate).toEqual([]) + expect(errors).toHaveLength(1) }) it('rolls back to a savepoint inside an open transaction', async () => { diff --git a/packages/api/src/lib/specimen-registration/index.ts b/packages/api/src/lib/specimen-registration/index.ts index d2e8fed..6646d22 100644 --- a/packages/api/src/lib/specimen-registration/index.ts +++ b/packages/api/src/lib/specimen-registration/index.ts @@ -9,8 +9,8 @@ import { planRegistration } from './plan' import { writeRegistration } from './write' import { SpecimenRegistrationError, - type RegistrationCheck, type RegistrationEntry, + type RegistrationError, type RegistrationOptions, type RegistrationResult, } from './types' @@ -34,21 +34,18 @@ export async function registerSpecimens( }) } -/** Run registerSpecimens and roll it back: its errors, or the Collections it would create. */ +/** Run registerSpecimens and roll it back, returning the errors it found (none if it would succeed). */ export async function checkSpecimenRegistration( database: Database, entries: RegistrationEntry[], options: RegistrationOptions = {}, -): Promise { +): Promise { return withRolledBackTransaction(database, async (db) => { try { - const result = await registerSpecimens(db, entries, options) - return { - errors: [], - collectionsToCreate: result.collectionsCreated.map(({ type, name }) => ({ type, name })), - } + await registerSpecimens(db, entries, options) + return [] } catch (error) { - if (error instanceof SpecimenRegistrationError) return { errors: error.errors, collectionsToCreate: [] } + if (error instanceof SpecimenRegistrationError) return error.errors throw error } }) diff --git a/packages/api/src/lib/specimen-registration/types.ts b/packages/api/src/lib/specimen-registration/types.ts index aa4ff14..3b26910 100644 --- a/packages/api/src/lib/specimen-registration/types.ts +++ b/packages/api/src/lib/specimen-registration/types.ts @@ -60,11 +60,6 @@ export type RegistrationResult = { collectionsCreated: CreatedCollection[] } -export type RegistrationCheck = { - errors: RegistrationError[] - collectionsToCreate: Array> -} - /** * Registration refused. Status 400: the input has problems, all listed. Status 409: a write hit * a database constraint the plan did not foresee (for example a concurrent registration). From 9dc98f90654cebe3f566b3cdb442f45084b77355 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:05:37 -0700 Subject: [PATCH 55/85] fix(api): check sheet name conflicts inside the sheet move transaction. The conflict check ran before withWriteTransaction, so two moves racing into one box both passed it and the loser got the UNIQUE constraint's generic 409. The checks and updates now run in the transaction with no await between them, so the loser gets the specific message. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/__tests__/write-atomicity.test.ts | 18 +++++ .../api/src/lib/collections/sheet-move.ts | 78 +++++++++---------- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/packages/api/src/lib/__tests__/write-atomicity.test.ts b/packages/api/src/lib/__tests__/write-atomicity.test.ts index 7e549d2..652d95d 100644 --- a/packages/api/src/lib/__tests__/write-atomicity.test.ts +++ b/packages/api/src/lib/__tests__/write-atomicity.test.ts @@ -126,4 +126,22 @@ describe('multi-step writes roll back on failure', () => { await expect(moveSheetsToCollection(testDb, [moving.id], boxB.id, 'box')).rejects.toBeInstanceOf(SheetNameConflictError) }) + + it('sheet move gives the name conflict, not a constraint error, to the losing side of a race', async () => { + const { loc } = await setupMicronixPlate() + const [boxA, boxB, target] = await Promise.all( + ['BoxA', 'BoxB', 'Target'].map((name) => createTestBox(testDb, { name, locationId: loc.id })), + ) + const fromA = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxA.id }) + const fromB = await createTestSheet(testDb, { name: 'Sheet-1', boxId: boxB.id }) + + const results = await Promise.allSettled([ + moveSheetsToCollection(testDb, [fromA.id], target.id, 'box'), + moveSheetsToCollection(testDb, [fromB.id], target.id, 'box'), + ]) + + expect(results.map((r) => r.status).sort()).toEqual(['fulfilled', 'rejected']) + const rejected = results.find((r) => r.status === 'rejected') as PromiseRejectedResult + expect(rejected.reason).toBeInstanceOf(SheetNameConflictError) + }) }) diff --git a/packages/api/src/lib/collections/sheet-move.ts b/packages/api/src/lib/collections/sheet-move.ts index e169c94..507e89d 100644 --- a/packages/api/src/lib/collections/sheet-move.ts +++ b/packages/api/src/lib/collections/sheet-move.ts @@ -30,50 +30,46 @@ export async function moveSheetsToCollection( targetCollectionId: number, targetCollectionType: 'box' | 'bag', ): Promise<{ moved: number }> { - if (targetCollectionType === 'box') { - const exists = await database.select().from(box).where(eq(box.id, targetCollectionId)).get() - if (!exists) throw new SheetMoveTargetNotFoundError('box') - // Sheet names are unique within a box (bags still allow repeated legacy names). - const moving = sheetIds.length > 0 - ? await database.select({ id: sheet.id, name: sheet.name }).from(sheet).where(inArray(sheet.id, sheetIds)) - : [] - const staying = await database - .select({ id: sheet.id, name: sheet.name }) - .from(sheet) - .where(eq(sheet.boxId, targetCollectionId)) - const movingIds = new Set(moving.map((s) => s.id)) - const taken = new Set(staying.filter((s) => !movingIds.has(s.id)).map((s) => s.name)) - for (const s of moving) { - if (taken.has(s.name)) throw new SheetNameConflictError(s.name) - taken.add(s.name) + // Checks and updates run synchronously inside the transaction: with no await between + // them, a concurrent move cannot slip in, so its loser gets SheetNameConflictError + // rather than the UNIQUE constraint error. + await withWriteTransaction(database, async (tx) => { + const target = + targetCollectionType === 'box' + ? tx.select({ id: box.id }).from(box).where(eq(box.id, targetCollectionId)).get() + : tx.select({ id: bag.id }).from(bag).where(eq(bag.id, targetCollectionId)).get() + if (!target) throw new SheetMoveTargetNotFoundError(targetCollectionType) + + if (targetCollectionType === 'box') { + // Sheet names are unique within a box (bags still allow repeated legacy names). + const moving = + sheetIds.length > 0 + ? tx.select({ id: sheet.id, name: sheet.name }).from(sheet).where(inArray(sheet.id, sheetIds)).all() + : [] + const staying = tx + .select({ id: sheet.id, name: sheet.name }) + .from(sheet) + .where(eq(sheet.boxId, targetCollectionId)) + .all() + const movingIds = new Set(moving.map((s) => s.id)) + const taken = new Set(staying.filter((s) => !movingIds.has(s.id)).map((s) => s.name)) + for (const s of moving) { + if (taken.has(s.name)) throw new SheetNameConflictError(s.name) + taken.add(s.name) + } } - } else { - const exists = await database.select().from(bag).where(eq(bag.id, targetCollectionId)).get() - if (!exists) throw new SheetMoveTargetNotFoundError('bag') - } - await withWriteTransaction(database, async (tx) => { for (const sheetId of sheetIds) { - const updated = - targetCollectionType === 'box' - ? await tx - .update(sheet) - .set({ - boxId: targetCollectionId, - bagId: null, - lastUpdated: sql`current_timestamp`, - }) - .where(eq(sheet.id, sheetId)) - .returning() - : await tx - .update(sheet) - .set({ - bagId: targetCollectionId, - boxId: null, - lastUpdated: sql`current_timestamp`, - }) - .where(eq(sheet.id, sheetId)) - .returning() + const updated = tx + .update(sheet) + .set({ + boxId: targetCollectionType === 'box' ? targetCollectionId : null, + bagId: targetCollectionType === 'bag' ? targetCollectionId : null, + lastUpdated: sql`current_timestamp`, + }) + .where(eq(sheet.id, sheetId)) + .returning({ id: sheet.id }) + .all() if (updated.length === 0) { throw new SheetNotFoundError(sheetId) } From b8e5a0a2398a9b9f0acf0934f33f8b7f550799c0 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:06:22 -0700 Subject: [PATCH 56/85] refactor(web): let the ContainerTypeUnitsManager effect call its loader directly. Handlers reload by bumping a counter the effect depends on, so loadData can be the Effect Event and the loadInitialData wrapper goes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/ContainerTypeUnitsManager.tsx | 16 +++---- .../ContainerTypeUnitsManager.test.tsx | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/components/__tests__/ContainerTypeUnitsManager.test.tsx diff --git a/packages/web/src/components/ContainerTypeUnitsManager.tsx b/packages/web/src/components/ContainerTypeUnitsManager.tsx index 1c6a31e..213c565 100644 --- a/packages/web/src/components/ContainerTypeUnitsManager.tsx +++ b/packages/web/src/components/ContainerTypeUnitsManager.tsx @@ -52,15 +52,13 @@ export default function ContainerTypeUnitsManager({ // All available units const [allUnits, setAllUnits] = useState([]) - // loadData is also called from handlers, so the mount effect goes through an Effect Event. - const loadInitialData = useEffectEvent(() => { - void loadData() - }) + // Bumped by handlers to reload; the effect owns loading. + const [reloadCount, setReloadCount] = useState(0) useEffect(() => { - loadInitialData() - }, []) + void loadData() + }, [reloadCount]) - const loadData = async () => { + const loadData = useEffectEvent(async () => { setLoading(true) setError(null) try { @@ -114,7 +112,7 @@ export default function ContainerTypeUnitsManager({ } finally { setLoading(false) } - } + }) const handleToggleUnit = async (containerType: ContainerType, unitId: number) => { const currentAllowed = allowedUnits[containerType] @@ -152,7 +150,7 @@ export default function ContainerTypeUnitsManager({ setError(errorMsg) onError?.(errorMsg) // Reload to get correct state - await loadData() + setReloadCount((n) => n + 1) } } diff --git a/packages/web/src/components/__tests__/ContainerTypeUnitsManager.test.tsx b/packages/web/src/components/__tests__/ContainerTypeUnitsManager.test.tsx new file mode 100644 index 0000000..a2f5e66 --- /dev/null +++ b/packages/web/src/components/__tests__/ContainerTypeUnitsManager.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '../../__tests__/helpers/render' +import ContainerTypeUnitsManager from '../ContainerTypeUnitsManager' +import { settingsApi } from '../../lib/api/settings' +import { unitsApi } from '../../lib/api/reference-data' + +vi.mock('../../lib/api/settings', async () => { + const { createMockedDomainModule } = await import('../../__tests__/helpers/mock-api') + return createMockedDomainModule('settings', { + settingsApi: { + getValue: vi.fn(), + getContainerTypeUnits: vi.fn(), + addContainerTypeUnit: vi.fn(), + removeContainerTypeUnit: vi.fn(), + }, + }) +}) + +vi.mock('../../lib/api/reference-data', async () => { + const { createMockedDomainModule } = await import('../../__tests__/helpers/mock-api') + return createMockedDomainModule('reference-data', { unitsApi: { listAll: vi.fn() } }) +}) + +describe('ContainerTypeUnitsManager', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(unitsApi.listAll).mockResolvedValue([{ id: 1, symbol: 'uL', name: 'microliter', category: 'volume' }]) + vi.mocked(settingsApi.getValue).mockResolvedValue(null) + vi.mocked(settingsApi.getContainerTypeUnits).mockResolvedValue({ units: [] } as never) + }) + + it('loads on mount, and reloads after a failed toggle', async () => { + vi.mocked(settingsApi.addContainerTypeUnit).mockRejectedValue(new Error('nope')) + const onError = vi.fn() + await render() + await waitFor(() => expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0)) + expect(unitsApi.listAll).toHaveBeenCalledTimes(1) + + await userEvent.setup().click(screen.getAllByRole('checkbox')[0]!) + + await waitFor(() => expect(unitsApi.listAll).toHaveBeenCalledTimes(2)) + expect(onError).toHaveBeenCalledWith('Failed to update unit relationship') + }) +}) From 4110ba2770622a966dc852003cb1458025d23dea Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:06:46 -0700 Subject: [PATCH 57/85] fix(db): name the qPCR well result UNIQUE constraint as the schema does. Migration 007 and initial_schema.sql now create qpcr_well_result_run_well_target_unique, matching the Drizzle schema. Databases that already have the table keep their unnamed UNIQUE (IF NOT EXISTS skips them); SQLite never looks constraints up by name, so both enforce the same rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/initial_schema.sql | 2 +- .../src/db/__tests__/schema-evolution.test.ts | 47 +++++++++++++++++++ .../db/migrations/007_qpcr_result_tables.sql | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/api/initial_schema.sql b/packages/api/initial_schema.sql index ab2c577..ce2b56e 100644 --- a/packages/api/initial_schema.sql +++ b/packages/api/initial_schema.sql @@ -410,7 +410,7 @@ CREATE TABLE IF NOT EXISTS qpcr_well_result ( quantity REAL, standard_quantity REAL, amp_status TEXT, - UNIQUE (qpcr_run_id, well_position, target_name) + CONSTRAINT qpcr_well_result_run_well_target_unique UNIQUE (qpcr_run_id, well_position, target_name) );--> statement-breakpoint CREATE INDEX IF NOT EXISTS qpcr_well_result_run_idx ON qpcr_well_result(qpcr_run_id);--> statement-breakpoint CREATE TABLE IF NOT EXISTS qpcr_amplification_data ( diff --git a/packages/api/src/db/__tests__/schema-evolution.test.ts b/packages/api/src/db/__tests__/schema-evolution.test.ts index cd591ad..65caa5a 100644 --- a/packages/api/src/db/__tests__/schema-evolution.test.ts +++ b/packages/api/src/db/__tests__/schema-evolution.test.ts @@ -293,6 +293,53 @@ describe('schema evolution', () => { sqlite.close() }) + /** The CREATE TABLE text SQLite stored for qpcr_well_result. */ + function qpcrWellResultDdl(sqlite: Database): string { + const row = sqlite.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='qpcr_well_result'").get() as + | { sql: string } + | null + return row?.sql ?? '' + } + + it('names the qPCR well result unique constraint as the Drizzle schema does, fresh or migrated', () => { + const fresh = new Database(testDbPath) + evolveOperationalSchema(fresh) + expect(qpcrWellResultDdl(fresh)).toContain('CONSTRAINT qpcr_well_result_run_well_target_unique UNIQUE') + fresh.close() + unlinkSync(testDbPath) + + const migrated = new Database(testDbPath) + migrated.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)') + migrated.exec('INSERT INTO schema_version (version) VALUES (6)') + evolveOperationalSchema(migrated) + expect(getRecordedSchemaVersion(migrated)).toBe(CURRENT_SCHEMA_VERSION) + expect(qpcrWellResultDdl(migrated)).toContain('CONSTRAINT qpcr_well_result_run_well_target_unique UNIQUE') + migrated.close() + }) + + it('migration 007 leaves an existing qpcr_well_result table and its unnamed UNIQUE as they are', () => { + const sqlite = new Database(testDbPath) + sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)') + sqlite.exec('INSERT INTO schema_version (version) VALUES (6)') + const existing = `CREATE TABLE qpcr_well_result ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + qpcr_run_id INTEGER NOT NULL, + well_position TEXT NOT NULL, + target_name TEXT, + UNIQUE (qpcr_run_id, well_position, target_name) + )` + sqlite.exec(existing) + + evolveOperationalSchema(sqlite) + + expect(qpcrWellResultDdl(sqlite)).toBe(existing) + sqlite.exec(`INSERT INTO qpcr_well_result (qpcr_run_id, well_position, target_name) VALUES (1, 'A01', 'T')`) + expect(() => + sqlite.exec(`INSERT INTO qpcr_well_result (qpcr_run_id, well_position, target_name) VALUES (1, 'A01', 'T')`), + ).toThrow(/UNIQUE/) + sqlite.close() + }) + it('fail-hard: invalid migration leaves schema_version unchanged', () => { const sqlite = new Database(testDbPath) sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)') diff --git a/packages/api/src/db/migrations/007_qpcr_result_tables.sql b/packages/api/src/db/migrations/007_qpcr_result_tables.sql index d0af7a9..006a7ea 100644 --- a/packages/api/src/db/migrations/007_qpcr_result_tables.sql +++ b/packages/api/src/db/migrations/007_qpcr_result_tables.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS qpcr_well_result ( quantity REAL, standard_quantity REAL, amp_status TEXT, - UNIQUE (qpcr_run_id, well_position, target_name) + CONSTRAINT qpcr_well_result_run_well_target_unique UNIQUE (qpcr_run_id, well_position, target_name) );--> statement-breakpoint CREATE INDEX IF NOT EXISTS qpcr_well_result_run_idx ON qpcr_well_result(qpcr_run_id);--> statement-breakpoint CREATE TABLE IF NOT EXISTS qpcr_amplification_data ( From c7b6e9862e0e5a77d96e66779b41e89079dd6a90 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:06:48 -0700 Subject: [PATCH 58/85] test(web): stub scrollIntoView in the web test setup instead of guarding it in Locations. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/__tests__/README.md | 2 +- packages/web/src/__tests__/setup.ts | 5 +++++ packages/web/src/pages/Locations.tsx | 3 +-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/web/src/__tests__/README.md b/packages/web/src/__tests__/README.md index 747bc19..8d4189a 100644 --- a/packages/web/src/__tests__/README.md +++ b/packages/web/src/__tests__/README.md @@ -38,7 +38,7 @@ Reference-data list helpers still return `{ data: T[], meta? }` — keep that en - `render.tsx`: Custom render function with providers (QueryClient, BrowserRouter, ToastProvider) - `mock-api.ts`: `createMockedDomainModule()` — `importActual` per `lib/api/` + per-test overrides - `mock-api-templates.ts`: domain presets (`statisticsPageMock`, `studiesPageMock`, etc.) for common page/hook tests - - `setup.ts`: Global test setup (IntersectionObserver, in-memory localStorage; default mocks for `auth` and `settings`) + - `setup.ts`: Global test setup (IntersectionObserver and `scrollIntoView` stubs, in-memory localStorage; default mocks for `auth` and `settings`) - `__tests__/fixtures/` - Test data fixtures - `lib/__tests__/` - Lib unit tests (e.g. commands, constants, plate-filename-match, container-types, localUserHistory, hotkeys, error-logger) - `components/__tests__/` - Component tests (e.g. BulkImportFlow, ContainerDerivationModal) diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index a527ac3..d523869 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -47,6 +47,11 @@ if (typeof window !== 'undefined' && typeof window.IntersectionObserver === 'und window.IntersectionObserver = MockIO } +// jsdom does not implement scrollIntoView; production code calls it unguarded. +if (typeof Element !== 'undefined' && typeof Element.prototype.scrollIntoView !== 'function') { + Element.prototype.scrollIntoView = () => {} +} + // Cleanup after each test afterEach(() => { cleanup() diff --git a/packages/web/src/pages/Locations.tsx b/packages/web/src/pages/Locations.tsx index 8c27e4a..95a19bc 100644 --- a/packages/web/src/pages/Locations.tsx +++ b/packages/web/src/pages/Locations.tsx @@ -158,8 +158,7 @@ export default function Locations() { if (nodeRef && tree) { // Small delay to ensure DOM is updated setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- jsdom has no scrollIntoView - nodeRef.scrollIntoView?.({ + nodeRef.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest', From c8938b032c8105dafaeca41d67bee9545349c66e Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:07:11 -0700 Subject: [PATCH 59/85] style(web): drop trailing whitespace the lint fixes left behind. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/components/StudyStats.tsx | 2 +- packages/web/src/components/TableViewConfigurationsManager.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/StudyStats.tsx b/packages/web/src/components/StudyStats.tsx index 565ea88..6a5ef54 100644 --- a/packages/web/src/components/StudyStats.tsx +++ b/packages/web/src/components/StudyStats.tsx @@ -32,7 +32,7 @@ const getBinKey = (dateString: string, size: BinSize): string => { const year = date.getFullYear() const month = date.getMonth() + 1 const day = date.getDate() - + switch (size) { case 'day': return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` diff --git a/packages/web/src/components/TableViewConfigurationsManager.tsx b/packages/web/src/components/TableViewConfigurationsManager.tsx index 72b436b..6f65453 100644 --- a/packages/web/src/components/TableViewConfigurationsManager.tsx +++ b/packages/web/src/components/TableViewConfigurationsManager.tsx @@ -39,7 +39,7 @@ export default function TableViewConfigurationsManager({ setLoading(true) try { const configsValue = await settingsApi.getValue('table_view_configurations') - const configs = configsValue?.configurations ?? [] + const configs = configsValue?.configurations ?? [] setConfigurations(configs) } catch (err: unknown) { const msg = From a14d39bc3ce8a34931e7d3cf23bed8f11b3b4f22 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:08:48 -0700 Subject: [PATCH 60/85] fix(web): say "Control definition not found" when the batch wizard's definition 404s. The PR dropped the client-side check and relied on the summary route's 404, which showed whichever of two server messages arrived first, after a retry. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/hooks/useControls.ts | 3 + packages/web/src/pages/ControlBatchWizard.tsx | 4 +- .../__tests__/ControlBatchWizard.test.tsx | 63 +++++++++++++++++++ packages/web/src/ui/async.ts | 8 +++ packages/web/src/ui/index.ts | 2 +- 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/pages/__tests__/ControlBatchWizard.test.tsx diff --git a/packages/web/src/hooks/useControls.ts b/packages/web/src/hooks/useControls.ts index 080ea73..8579618 100644 --- a/packages/web/src/hooks/useControls.ts +++ b/packages/web/src/hooks/useControls.ts @@ -4,6 +4,7 @@ import { strainsApi } from '../lib/api/reference-data' import type { Strain } from '../lib/api/reference-data' import { getCompositionKey } from '../lib/composition-key' import { localToday } from '../lib/date-utils' +import { getHttpStatus } from '../ui/async' export const controlKeys = { all: ['blood-controls'] as const, @@ -121,6 +122,8 @@ export function useControlDefinitionWizardSeed(definitionId: number | undefined) ]) return { summaryResponse, suggestedName: nameResponse.name, productionDate: today } }, + // A missing definition stays missing; show it now instead of after a retry. + retry: (failureCount, error) => getHttpStatus(error) !== 404 && failureCount < 1, enabled: !!definitionId && definitionId > 0, }) } diff --git a/packages/web/src/pages/ControlBatchWizard.tsx b/packages/web/src/pages/ControlBatchWizard.tsx index 2e7ccb4..9343a61 100644 --- a/packages/web/src/pages/ControlBatchWizard.tsx +++ b/packages/web/src/pages/ControlBatchWizard.tsx @@ -15,7 +15,7 @@ import { useControlDefinitionWizardSeed, } from '../hooks/useControls' import { useUser } from '../contexts/UserContext' -import { PageError, fromQuery, getQueryErrorMessage } from '../ui' +import { PageError, fromQuery, getHttpStatus, getQueryErrorMessage } from '../ui' import '../styles/blood-controls.css' import { localToday } from '../lib/date-utils' @@ -203,6 +203,8 @@ function ControlBatchWizardContent() { return getQueryErrorMessage(batchBootstrapQuery.error, 'Failed to load batch') } if (isCreateFromDefinition && definitionSeedQuery.isError) { + // The definition summary and batch-name routes both 404 for a missing or non-blood definition. + if (getHttpStatus(definitionSeedQuery.error) === 404) return 'Control definition not found' return getQueryErrorMessage(definitionSeedQuery.error, 'Failed to load control definition') } if (isCompositionCsvFlow && compositionQuery.isError) { diff --git a/packages/web/src/pages/__tests__/ControlBatchWizard.test.tsx b/packages/web/src/pages/__tests__/ControlBatchWizard.test.tsx new file mode 100644 index 0000000..6626935 --- /dev/null +++ b/packages/web/src/pages/__tests__/ControlBatchWizard.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Route, Routes } from 'react-router-dom' +import { render, screen } from '../../__tests__/helpers/render' +import ControlBatchWizard from '../ControlBatchWizard' +import { controlsApi } from '../../lib/api/controls' + +vi.mock('../../lib/api/controls', async () => { + const { createMockedDomainModule } = await import('../../__tests__/helpers/mock-api') + return createMockedDomainModule('controls', { + controlsApi: { getDefinitionSummary: vi.fn(), suggestBatchName: vi.fn() }, + }) +}) + +vi.mock('../../contexts/UserContext', async () => { + const actual = await vi.importActual('../../contexts/UserContext') + return { ...actual, useUser: () => ({ canWrite: true }) } +}) + +const notFound = (error: string) => Object.assign(new Error('Request failed with status code 404'), { + response: { status: 404, data: { error } }, +}) + +function renderWizard() { + return render( + + } /> + , + { initialEntries: ['/blood-controls/999/batches/new'] }, + ) +} + +describe('ControlBatchWizard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('says the control definition was not found when its summary 404s', async () => { + vi.mocked(controlsApi.getDefinitionSummary).mockRejectedValue(notFound('Blood control with id 999 not found')) + vi.mocked(controlsApi.suggestBatchName).mockResolvedValue({ name: 'unused' }) + await renderWizard() + + expect(await screen.findByText('Control definition not found')).toBeInTheDocument() + expect(controlsApi.getDefinitionSummary).toHaveBeenCalledTimes(1) + }) + + it('shows the same message when the batch-name lookup 404s first', async () => { + vi.mocked(controlsApi.getDefinitionSummary).mockReturnValue(new Promise(() => {})) + vi.mocked(controlsApi.suggestBatchName).mockRejectedValue(notFound('Control definition not found')) + await renderWizard() + + expect(await screen.findByText('Control definition not found')).toBeInTheDocument() + }) + + it('shows the server message for other failures', async () => { + vi.mocked(controlsApi.getDefinitionSummary).mockRejectedValue( + Object.assign(new Error('boom'), { response: { status: 500, data: { error: 'Database is locked' } } }), + ) + vi.mocked(controlsApi.suggestBatchName).mockResolvedValue({ name: 'unused' }) + await renderWizard() + + expect(await screen.findByText('Database is locked', {}, { timeout: 3000 })).toBeInTheDocument() + }) +}) diff --git a/packages/web/src/ui/async.ts b/packages/web/src/ui/async.ts index 85a6910..0e7cd4a 100644 --- a/packages/web/src/ui/async.ts +++ b/packages/web/src/ui/async.ts @@ -18,6 +18,14 @@ export function fromQuery( return 'loading' } +/** HTTP status of a failed API call, or undefined for other errors. */ +export function getHttpStatus(error: unknown): number | undefined { + if (error && typeof error === 'object' && 'response' in error) { + return (error as { response?: { status?: number } }).response?.status + } + return undefined +} + export function getQueryErrorMessage(error: unknown, fallback: string): string { if (error && typeof error === 'object' && 'response' in error) { const response = (error as { response?: { data?: { error?: string } } }).response diff --git a/packages/web/src/ui/index.ts b/packages/web/src/ui/index.ts index f2fa155..96bd8f6 100644 --- a/packages/web/src/ui/index.ts +++ b/packages/web/src/ui/index.ts @@ -1,4 +1,4 @@ -export { type PresentationStatus, fromQuery, getQueryErrorMessage } from './async' +export { type PresentationStatus, fromQuery, getHttpStatus, getQueryErrorMessage } from './async' export { AsyncPresentation, type AsyncPresentationProps } from './AsyncPresentation' export { Button, buttonClassName, type ButtonProps, type ButtonVariant } from './Button' export { EmptyState, type EmptyStateProps } from './EmptyState' From 4bac33c3efb12a64c94c7163394b0ddf754e4943 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:09:16 -0700 Subject: [PATCH 61/85] test(web): wait for Apply to be enabled in the /specimens URL tests. Apply is disabled while the list loads; under a full test run the click could land before the search refetch finished. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/pages/__tests__/Specimens.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/web/src/pages/__tests__/Specimens.test.tsx b/packages/web/src/pages/__tests__/Specimens.test.tsx index 7f9a61f..ad600d7 100644 --- a/packages/web/src/pages/__tests__/Specimens.test.tsx +++ b/packages/web/src/pages/__tests__/Specimens.test.tsx @@ -26,6 +26,12 @@ async function renderAt(...initialEntries: string[]) { url: () => screen.getByLabelText('url').textContent, sourceType: () => screen.getByLabelText('Source Type'), search: () => screen.getByPlaceholderText(/Search subject/), + /** Apply is disabled while the list loads, so wait for it before clicking. */ + apply: async () => { + const button = screen.getByRole('button', { name: /apply filters/i }) + await waitFor(() => expect(button).toBeEnabled()) + fireEvent.click(button) + }, } } @@ -103,7 +109,7 @@ describe('Specimens page URL filters', () => { fireEvent.change(page.sourceType(), { target: { value: 'subject' } }) expect(page.url()).toBe('/specimens') - fireEvent.click(screen.getByRole('button', { name: /apply filters/i })) + await page.apply() await waitFor(() => expect(page.url()).toBe('/specimens?source_type=subject')) fireEvent.click(screen.getByRole('button', { name: 'Back' })) @@ -130,7 +136,7 @@ describe('Specimens page URL filters', () => { await waitFor(() => expect(page.url()).toBe('/specimens?search=QC')) expect(page.sourceType()).toHaveValue('control') - fireEvent.click(screen.getByRole('button', { name: /apply filters/i })) + await page.apply() await waitFor(() => expect(page.url()).toBe('/specimens?source_type=control&search=QC')) }) From 4b3309329c0e13921d77bcf2b68778605874412a Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:09:52 -0700 Subject: [PATCH 62/85] refactor(registration): remove the duplicated container type, error class, and lookups. - BulkCombinedContainerInput was RegistrationContainer under a second name; bulk combined import uses RegistrationContainer. - BulkCombinedRejected copied SpecimenRegistrationError. Both now extend RefusedWithErrors, which carries the status and the error list keyed the way each caller keys its input. - CollectionPlanner looked a plate up by id, barcode, and name with three copies of one query, and a box or bag with two. storedGrid and storedParent each do it once. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/bulk-combined-import.ts | 14 +-- .../lib/specimen-registration/collections.ts | 88 +++++++++---------- .../src/lib/specimen-registration/types.ts | 16 ++-- 3 files changed, 55 insertions(+), 63 deletions(-) diff --git a/packages/api/src/lib/bulk-combined-import.ts b/packages/api/src/lib/bulk-combined-import.ts index dd88c7f..8040298 100644 --- a/packages/api/src/lib/bulk-combined-import.ts +++ b/packages/api/src/lib/bulk-combined-import.ts @@ -13,6 +13,7 @@ import { resolveSpecimenTypeByName } from './identifier-resolution' import { ConflictError, ValidationError } from './error-handler' import { withRolledBackTransaction, withWriteTransaction } from '../db/write-transaction' import { + RefusedWithErrors, registerSpecimens, SpecimenRegistrationError, type RegistrationContainer, @@ -20,14 +21,12 @@ import { type RegistrationResult, } from './specimen-registration' -export type BulkCombinedContainerInput = RegistrationContainer - export { normalizePosition } from './normalize-position' export interface BulkCombinedSpecimenInput { specimenTypeName: string collectionDate?: string - container?: BulkCombinedContainerInput + container?: RegistrationContainer /** CSV row, echoed back on errors. */ rowIndex?: number } @@ -72,14 +71,7 @@ export interface BulkCombinedError { } /** The subjects could not all be registered; nothing was written. */ -class BulkCombinedRejected extends Error { - constructor( - readonly status: 400 | 409, - readonly errors: BulkCombinedError[], - ) { - super(errors[0]?.message ?? 'Bulk import failed') - } -} +class BulkCombinedRejected extends RefusedWithErrors {} /** Combined-import wording for module messages. */ function combinedMessage(message: string): string { diff --git a/packages/api/src/lib/specimen-registration/collections.ts b/packages/api/src/lib/specimen-registration/collections.ts index 7cbe0a1..ba3d8b2 100644 --- a/packages/api/src/lib/specimen-registration/collections.ts +++ b/packages/api/src/lib/specimen-registration/collections.ts @@ -105,39 +105,45 @@ export class CollectionPlanner { : `${LABELS[match.type]} '${match.name}' is stored at a different location from the one given.` } + /** The stored grid Collection whose id, barcode, or name equals `value`. */ + private storedGrid(type: GridCollectionType, by: 'id' | 'barcode' | 'name', value: number | string): Candidate | null { + const table = type === 'cryovial_box' ? cryovialBox : micronixPlate + const row = this.database + .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) + .from(table) + .where(eq(table[by], value)) + .get() + return row ? { target: { existingId: row.id }, type, name: row.name, barcode: row.barcode, locationId: row.locationId } : null + } + + /** The stored box or bag whose id or name equals `value`. */ + private storedParent(type: ParentCollectionType, by: 'id' | 'name', value: number | string): Candidate | null { + const table = type === 'box' ? box : bag + const row = this.database + .select({ id: table.id, name: table.name, locationId: table.locationId }) + .from(table) + .where(eq(table[by], value)) + .get() + return row ? { target: { existingId: row.id }, type, name: row.name, barcode: null, locationId: row.locationId } : null + } + async resolveGrid(type: GridCollectionType, collection: GridCollectionInput): Promise { const label = LABELS[type] - const table = type === 'cryovial_box' ? cryovialBox : micronixPlate const candidates: Candidate[] = [] if (collection.id != null) { - const row = await this.database - .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) - .from(table) - .where(eq(table.id, collection.id)) - .get() - if (!row) return { error: `${label} not found: id ${collection.id}` } - candidates.push({ target: { existingId: row.id }, type, ...row }) + const byId = this.storedGrid(type, 'id', collection.id) + if (!byId) return { error: `${label} not found: id ${collection.id}` } + candidates.push(byId) } - if (collection.barcode) { - const barcode = collection.barcode - const row = await this.database - .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) - .from(table) - .where(eq(table.barcode, barcode)) - .get() - if (row) candidates.push({ target: { existingId: row.id }, type, ...row }) - else candidates.push(...this.plannedCandidates(type, (p) => 'barcode' in p && p.barcode === barcode)) + const { barcode, name } = collection + if (barcode) { + const stored = this.storedGrid(type, 'barcode', barcode) + candidates.push(...(stored ? [stored] : this.plannedCandidates(type, (p) => 'barcode' in p && p.barcode === barcode))) } - if (collection.name) { - const name = collection.name - const row = await this.database - .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) - .from(table) - .where(eq(table.name, name)) - .get() - if (row) candidates.push({ target: { existingId: row.id }, type, ...row }) - else candidates.push(...this.plannedCandidates(type, (p) => p.name === name)) + if (name) { + const stored = this.storedGrid(type, 'name', name) + candidates.push(...(stored ? [stored] : this.plannedCandidates(type, (p) => p.name === name))) } const matches = distinctCandidates(candidates) @@ -167,8 +173,8 @@ export class CollectionPlanner { if (collection.locationId == null) { return { error: 'Collection not found and no location provided' } } - const name = collection.name ?? collection.barcode - if (!name) { + const newName = name ?? barcode + if (!newName) { return { error: type === 'cryovial_box' @@ -178,34 +184,24 @@ export class CollectionPlanner { } const badLocation = locationError(collection.locationId, this.database) if (badLocation) return { error: badLocation } - this.planned.push({ type, name, barcode: collection.barcode, locationId: collection.locationId }) + this.planned.push({ type, name: newName, barcode, locationId: collection.locationId }) return { target: { plannedIndex: this.planned.length - 1 } } } async resolveParent(parent: ParentCollectionInput): Promise { const type = parent.type const label = LABELS[type] - const table = type === 'box' ? box : bag const candidates: Candidate[] = [] if (parent.id != null) { - const row = await this.database - .select({ id: table.id, name: table.name, locationId: table.locationId }) - .from(table) - .where(eq(table.id, parent.id)) - .get() - if (!row) return { error: `${label} not found: id ${parent.id}` } - candidates.push({ target: { existingId: row.id }, type, barcode: null, ...row }) + const byId = this.storedParent(type, 'id', parent.id) + if (!byId) return { error: `${label} not found: id ${parent.id}` } + candidates.push(byId) } - if (parent.name) { - const name = parent.name - const row = await this.database - .select({ id: table.id, name: table.name, locationId: table.locationId }) - .from(table) - .where(eq(table.name, name)) - .get() - if (row) candidates.push({ target: { existingId: row.id }, type, barcode: null, ...row }) - else candidates.push(...this.plannedCandidates(type, (p) => p.name === name)) + const name = parent.name + if (name) { + const stored = this.storedParent(type, 'name', name) + candidates.push(...(stored ? [stored] : this.plannedCandidates(type, (p) => p.name === name))) } const matches = distinctCandidates(candidates) diff --git a/packages/api/src/lib/specimen-registration/types.ts b/packages/api/src/lib/specimen-registration/types.ts index 3b26910..42fb073 100644 --- a/packages/api/src/lib/specimen-registration/types.ts +++ b/packages/api/src/lib/specimen-registration/types.ts @@ -61,15 +61,19 @@ export type RegistrationResult = { } /** - * Registration refused. Status 400: the input has problems, all listed. Status 409: a write hit - * a database constraint the plan did not foresee (for example a concurrent registration). + * A write refused with every problem found, keyed however the caller keys its input. + * Status 400: the input has problems, all listed. Status 409: a write hit a database constraint + * the plan did not foresee (for example a concurrent registration). */ -export class SpecimenRegistrationError extends Error { +export class RefusedWithErrors extends Error { constructor( readonly status: 400 | 409, - readonly errors: RegistrationError[], + readonly errors: E[], ) { - super(errors[0]?.message ?? 'Specimen registration failed') - this.name = 'SpecimenRegistrationError' + super(errors[0]?.message ?? 'Request refused') + this.name = new.target.name } } + +/** Registration refused; errors are keyed to the registration's entries. */ +export class SpecimenRegistrationError extends RefusedWithErrors {} From 1db5affd4495a7136407a0831eede14fb77debac Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:10:21 -0700 Subject: [PATCH 63/85] refactor(activity): drop null checks on the non-null container specimenId. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/routes/activity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/routes/activity.ts b/packages/api/src/routes/activity.ts index c93a6cf..b12e653 100644 --- a/packages/api/src/routes/activity.ts +++ b/packages/api/src/routes/activity.ts @@ -104,7 +104,7 @@ export function createActivityRoutes(database: Database): Hono { // One batched Source lookup for every specimen in the feed: a fixed number of queries. const sources = await resolveSpecimenSources(database, [ ...recentSpecimens.map((s) => s.id), - ...recentContainers.flatMap((c) => (c.specimenId != null ? [c.specimenId] : [])), + ...recentContainers.map((c) => c.specimenId), ]) const enrichedSpecimens = recentSpecimens.map((spec) => { @@ -171,7 +171,7 @@ export function createActivityRoutes(database: Database): Hono { } let context: string | undefined = container.specimenTypeName ?? undefined - const source = container.specimenId != null ? sources.get(container.specimenId) : null + const source = sources.get(container.specimenId) if (source?.type === 'subject') { context = `${container.specimenTypeName || ''} • ${source.name} (${source.study.code})`.trim() } From 402ebf5850e852387b62a426c5c9a6c31ce41780 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:10:53 -0700 Subject: [PATCH 64/85] refactor(registration): map container types to collections and tables in one place. The step from a container type to its plate or box type, or to its table, was written out five times across plan.ts, collections.ts, and write.ts. tables.ts now holds those maps, and the planner and writer look types up there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/specimen-registration/collections.ts | 10 ++--- .../api/src/lib/specimen-registration/plan.ts | 7 ++- .../src/lib/specimen-registration/tables.ts | 22 ++++++++++ .../src/lib/specimen-registration/write.ts | 43 ++++++------------- 4 files changed, 42 insertions(+), 40 deletions(-) create mode 100644 packages/api/src/lib/specimen-registration/tables.ts diff --git a/packages/api/src/lib/specimen-registration/collections.ts b/packages/api/src/lib/specimen-registration/collections.ts index ba3d8b2..eac4cc0 100644 --- a/packages/api/src/lib/specimen-registration/collections.ts +++ b/packages/api/src/lib/specimen-registration/collections.ts @@ -6,15 +6,15 @@ import { and, eq } from 'drizzle-orm' import type { ContainerWriteInput } from '@sampledb/contract' import type { Database } from '../../db/client' -import { bag, box, cryovialBox, micronixPlate, sheet } from '../../db/schema' +import { sheet } from '../../db/schema' +import { GRID_COLLECTION_TABLES, PARENT_COLLECTION_TABLES, type GridCollectionType, type ParentCollectionType } from './tables' import { assertLocationCanContainCollections, CollectionLocationNotAllowedError, CollectionLocationNotFoundError, } from '../collections/collection-lifecycle' -export type GridCollectionType = 'micronix_plate' | 'cryovial_box' -export type ParentCollectionType = 'box' | 'bag' +export type { GridCollectionType, ParentCollectionType } from './tables' type GridCollectionInput = NonNullable< Extract['collection'] @@ -107,7 +107,7 @@ export class CollectionPlanner { /** The stored grid Collection whose id, barcode, or name equals `value`. */ private storedGrid(type: GridCollectionType, by: 'id' | 'barcode' | 'name', value: number | string): Candidate | null { - const table = type === 'cryovial_box' ? cryovialBox : micronixPlate + const table = GRID_COLLECTION_TABLES[type] const row = this.database .select({ id: table.id, name: table.name, barcode: table.barcode, locationId: table.locationId }) .from(table) @@ -118,7 +118,7 @@ export class CollectionPlanner { /** The stored box or bag whose id or name equals `value`. */ private storedParent(type: ParentCollectionType, by: 'id' | 'name', value: number | string): Candidate | null { - const table = type === 'box' ? box : bag + const table = PARENT_COLLECTION_TABLES[type] const row = this.database .select({ id: table.id, name: table.name, locationId: table.locationId }) .from(table) diff --git a/packages/api/src/lib/specimen-registration/plan.ts b/packages/api/src/lib/specimen-registration/plan.ts index ebf589f..45f6d8d 100644 --- a/packages/api/src/lib/specimen-registration/plan.ts +++ b/packages/api/src/lib/specimen-registration/plan.ts @@ -6,8 +6,6 @@ import { and, eq, isNull } from 'drizzle-orm' import type { Database } from '../../db/client' import { controlBatch, - cryovialTube, - micronixTube, paper, specimen, specimenType, @@ -22,6 +20,7 @@ import { validateContainerTypeForSpecimenType, validateUnitForContainerType, } from '../validation' +import { GRID_COLLECTION_OF, TUBE_TABLES } from './tables' import { CollectionPlanner, targetKey, type CollectionTarget, type PlannedCollection } from './collections' import { validateContainerWriteFields } from './container-fields' import type { RegistrationContainer, RegistrationEntry, RegistrationError } from './types' @@ -248,7 +247,7 @@ async function planContainer( } } } else { - const gridType = input.containerType === 'cryovial_tube' ? 'cryovial_box' : 'micronix_plate' + const gridType = GRID_COLLECTION_OF[input.containerType] const resolved = input.collection ? await planner.resolveGrid(gridType, input.collection) : { error: 'Collection name or barcode is required' } @@ -277,7 +276,7 @@ async function planContainer( const barcode = 'barcode' in input ? input.barcode?.trim() : undefined if (barcode && (input.containerType === 'micronix_tube' || input.containerType === 'cryovial_tube')) { - const table = input.containerType === 'micronix_tube' ? micronixTube : cryovialTube + const table = TUBE_TABLES[input.containerType] const taken = await database.select({ id: table.id }).from(table).where(eq(table.barcode, barcode)).get() if (taken) problems.push({ field: 'barcode', message: `Barcode '${barcode}' already exists. Use a different barcode.` }) if (!claims.take(`barcode:${input.containerType}:${barcode}`)) { diff --git a/packages/api/src/lib/specimen-registration/tables.ts b/packages/api/src/lib/specimen-registration/tables.ts new file mode 100644 index 0000000..2cec378 --- /dev/null +++ b/packages/api/src/lib/specimen-registration/tables.ts @@ -0,0 +1,22 @@ +/** Which Collection each container type goes in, and the table behind each type. One place. */ +import { bag, box, cryovialBox, cryovialTube, micronixPlate, micronixTube, staticWell } from '../../db/schema' + +export type GridContainerType = 'micronix_tube' | 'cryovial_tube' | 'static_well' +export type GridCollectionType = 'micronix_plate' | 'cryovial_box' +export type ParentCollectionType = 'box' | 'bag' + +/** The plate or box a tube or well goes in. */ +export const GRID_COLLECTION_OF: Record = { + micronix_tube: 'micronix_plate', + cryovial_tube: 'cryovial_box', + static_well: 'micronix_plate', +} + +export const GRID_COLLECTION_TABLES = { micronix_plate: micronixPlate, cryovial_box: cryovialBox } as const + +export const PARENT_COLLECTION_TABLES = { box, bag } as const + +/** Tube and well tables; only tubes carry a barcode. */ +export const GRID_CONTAINER_TABLES = { micronix_tube: micronixTube, cryovial_tube: cryovialTube, static_well: staticWell } as const + +export const TUBE_TABLES = { micronix_tube: micronixTube, cryovial_tube: cryovialTube } as const diff --git a/packages/api/src/lib/specimen-registration/write.ts b/packages/api/src/lib/specimen-registration/write.ts index 9d1ff45..f55e483 100644 --- a/packages/api/src/lib/specimen-registration/write.ts +++ b/packages/api/src/lib/specimen-registration/write.ts @@ -1,23 +1,11 @@ /** Carry out a registration plan that has no errors. Call inside a write transaction. */ import type { Database } from '../../db/client' -import { - bag, - box, - cryovialBox, - cryovialTube, - micronixPlate, - micronixTube, - paper, - sheet, - specimen, - staticWell, - storageContainer, - type Specimen, -} from '../../db/schema' +import { paper, sheet, specimen, storageContainer, type Specimen } from '../../db/schema' import { utcNow } from '../datetime' import { sqliteConstraintConflict } from '../error-handler' import type { CollectionTarget, PlannedCollection } from './collections' import type { RegistrationPlan } from './plan' +import { GRID_COLLECTION_TABLES, GRID_CONTAINER_TABLES, PARENT_COLLECTION_TABLES } from './tables' import { SpecimenRegistrationError, type CreatedCollection, @@ -72,14 +60,14 @@ export async function writeRegistration( switch (planned.type) { case 'micronix_plate': case 'cryovial_box': { - const table = planned.type === 'micronix_plate' ? micronixPlate : cryovialBox + const table = GRID_COLLECTION_TABLES[planned.type] const values = { name: planned.name, barcode: planned.barcode ?? null, locationId: planned.locationId, ...audit } id = only(await database.insert(table).values(values).returning({ id: table.id }), planned.type).id break } case 'box': case 'bag': { - const table = planned.type === 'box' ? box : bag + const table = PARENT_COLLECTION_TABLES[planned.type] const values = { name: planned.name, locationId: planned.locationId, ...audit } id = only(await database.insert(table).values(values).returning({ id: table.id }), planned.type).id break @@ -140,21 +128,14 @@ export async function writeRegistration( 'container', ) const collectionId = idOf(container.collection) - switch (input.containerType) { - case 'micronix_tube': - await database.insert(micronixTube).values({ id, collectionId, barcode: input.barcode.trim(), position: container.position }) - break - case 'cryovial_tube': - await database - .insert(cryovialTube) - .values({ id, collectionId, barcode: input.barcode?.trim() || null, position: container.position }) - break - case 'static_well': - await database.insert(staticWell).values({ id, collectionId, position: container.position }) - break - case 'paper': - await database.insert(paper).values({ id, sheetId: collectionId, sublabel: input.sublabel?.trim() || null }) - break + if (input.containerType === 'paper') { + await database.insert(paper).values({ id, sheetId: collectionId, sublabel: input.sublabel?.trim() || null }) + } else { + // The plan checked that a micronix tube has a barcode. + const barcode = 'barcode' in input ? input.barcode?.trim() || null : null + await database + .insert(GRID_CONTAINER_TABLES[input.containerType]) + .values({ id, collectionId, position: container.position, ...(barcode !== null && { barcode }) }) } entry.containerIds.push(id) } From 87aa5c5dc0e8e5b2a3a10f4d781d551a273656dc Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:12:10 -0700 Subject: [PATCH 65/85] refactor(registration): replace string claim keys, -1 sentinels, and message matching. - Request claims were template strings built at each call site. A Claim union (barcode, position, sublabel) now has one key builder. - entrySpecimen marked an entry with errors as -1; it is now undefined, and findSpecimen takes the Source union instead of falling back to controlBatchId -1. - Bulk combined import reworded a missing position by matching "Position (well) is required" in the message. validateContainerWriteFields now returns the field each error belongs to, and its position message ("Position is required for micronix tubes (e.g. A01).") serves every caller, so the rewording is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/bulk-combined-import.ts | 10 +-- .../__tests__/specimen-registration.test.ts | 8 +++ .../specimen-registration/container-fields.ts | 27 ++++---- .../api/src/lib/specimen-registration/plan.ts | 62 ++++++++++++------- .../src/lib/specimen-registration/write.ts | 1 + 5 files changed, 65 insertions(+), 43 deletions(-) diff --git a/packages/api/src/lib/bulk-combined-import.ts b/packages/api/src/lib/bulk-combined-import.ts index 8040298..aacbfb3 100644 --- a/packages/api/src/lib/bulk-combined-import.ts +++ b/packages/api/src/lib/bulk-combined-import.ts @@ -73,14 +73,6 @@ export interface BulkCombinedError { /** The subjects could not all be registered; nothing was written. */ class BulkCombinedRejected extends RefusedWithErrors {} -/** Combined-import wording for module messages. */ -function combinedMessage(message: string): string { - if (message.includes('Position (well) is required')) { - return 'Position is required for this container type (e.g. A01)' - } - return message -} - /** A subject's own study wins over the payload-level default. */ export function resolveSubjectStudyShortCode( payloadStudyShortCode: string | undefined, @@ -185,7 +177,7 @@ async function registerSubjects( registration = await registerSpecimens(database, entries, { userId }) } catch (error) { if (!(error instanceof SpecimenRegistrationError)) throw error - const mapped = error.errors.map((e) => ({ ...entryRefs[e.entryIndex], message: combinedMessage(e.message) })) + const mapped = error.errors.map((e) => ({ ...entryRefs[e.entryIndex], message: e.message })) if (error.status === 409) throw new BulkCombinedRejected(409, [...errors, ...mapped]) errors.push(...mapped) } diff --git a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts index 0947292..388a085 100644 --- a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts +++ b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts @@ -294,6 +294,14 @@ describe('Specimen registration', () => { }) }) + it('keys a missing position to the position field', async () => { + const error = await rejection([entry([tube('T1', { name: 'P1' })])]) + + expect(error.errors).toEqual([ + { entryIndex: 0, containerIndex: 0, field: 'position', message: 'Position is required for micronix tubes (e.g. A01).' }, + ]) + }) + it('rejects a unit not allowed for the container type', async () => { const other = await createTestUnit(db, { symbol: 'mg', name: 'milligram', category: 'mass' }) diff --git a/packages/api/src/lib/specimen-registration/container-fields.ts b/packages/api/src/lib/specimen-registration/container-fields.ts index 1c298a6..4229bdf 100644 --- a/packages/api/src/lib/specimen-registration/container-fields.ts +++ b/packages/api/src/lib/specimen-registration/container-fields.ts @@ -5,52 +5,55 @@ function hasGridCollection(collection: { id?: number; name?: string; barcode?: s return collection.id != null || Boolean(collection.name) || Boolean(collection.barcode) } +/** The input field a missing or misplaced value belongs to. */ +export type ContainerWriteField = 'barcode' | 'sublabel' | 'collection' | 'position' + /** * Required ContainerWriteInput fields by type (no DB uniqueness checks). */ export function validateContainerWriteFields( input: ContainerWriteInput -): { valid: boolean; error?: string } { +): { valid: true } | { valid: false; field: ContainerWriteField; error: string } { if (input.containerType === 'micronix_tube') { if (!input.barcode) { - return { valid: false, error: 'Barcode is required for micronix tubes' } + return { valid: false, field: 'barcode', error: 'Barcode is required for micronix tubes' } } if (!hasGridCollection(input.collection)) { - return { valid: false, error: 'Collection name or barcode is required' } + return { valid: false, field: 'collection', error: 'Collection name or barcode is required' } } if (!input.collection?.position || String(input.collection.position).trim() === '') { - return { valid: false, error: 'Position (well) is required for micronix tubes.' } + return { valid: false, field: 'position', error: 'Position is required for micronix tubes (e.g. A01).' } } } else if (input.containerType === 'cryovial_tube') { if (!hasGridCollection(input.collection)) { - return { valid: false, error: 'Collection name or barcode is required' } + return { valid: false, field: 'collection', error: 'Collection name or barcode is required' } } if (!input.collection?.position || String(input.collection.position).trim() === '') { - return { valid: false, error: 'Position (well) is required for cryovial tubes.' } + return { valid: false, field: 'position', error: 'Position is required for cryovial tubes (e.g. A01).' } } } else if (input.containerType === 'paper') { const barcode = Reflect.get(input, 'barcode') if (typeof barcode === 'string' && barcode !== '') { - return { valid: false, error: 'Paper containers use sublabel for spot identifiers, not barcode' } + return { valid: false, field: 'sublabel', error: 'Paper containers use sublabel for spot identifiers, not barcode' } } const collection = input.collection if (!collection) { - return { valid: false, error: 'Collection is required for paper containers' } + return { valid: false, field: 'collection', error: 'Collection is required for paper containers' } } if (collection.id == null) { if (!collection.parent || (collection.parent.id == null && !collection.parent.name)) { - return { valid: false, error: 'Collection name is required for papers' } + return { valid: false, field: 'collection', error: 'Collection name is required for papers' } } if (!collection.name) { - return { valid: false, error: 'Sheet name is required for papers' } + return { valid: false, field: 'collection', error: 'Sheet name is required for papers' } } } } else { if (!hasGridCollection(input.collection)) { - return { valid: false, error: 'Collection name or barcode is required' } + return { valid: false, field: 'collection', error: 'Collection name or barcode is required' } } if (!input.collection?.position || String(input.collection.position).trim() === '') { - return { valid: false, error: 'Position (well) is required for static wells.' } + return { valid: false, field: 'position', error: 'Position is required for static wells (e.g. A01).' } } } diff --git a/packages/api/src/lib/specimen-registration/plan.ts b/packages/api/src/lib/specimen-registration/plan.ts index 45f6d8d..814335a 100644 --- a/packages/api/src/lib/specimen-registration/plan.ts +++ b/packages/api/src/lib/specimen-registration/plan.ts @@ -21,9 +21,9 @@ import { validateUnitForContainerType, } from '../validation' import { GRID_COLLECTION_OF, TUBE_TABLES } from './tables' -import { CollectionPlanner, targetKey, type CollectionTarget, type PlannedCollection } from './collections' +import { CollectionPlanner, targetKey, type CollectionTarget, type GridCollectionType, type PlannedCollection } from './collections' import { validateContainerWriteFields } from './container-fields' -import type { RegistrationContainer, RegistrationEntry, RegistrationError } from './types' +import type { RegistrationContainer, RegistrationEntry, RegistrationError, RegistrationSource } from './types' export type PlannedSpecimen = | { existing: Specimen } @@ -49,22 +49,22 @@ export type PlannedContainer = { export type RegistrationPlan = { specimens: PlannedSpecimen[] - /** entries[i] registers specimens[entrySpecimen[i]]. */ - entrySpecimen: number[] + /** entries[i] registers specimens[entrySpecimen[i]]; undefined when the entry has errors. */ + entrySpecimen: Array containers: PlannedContainer[] collections: PlannedCollection[] } function findSpecimen( database: Database, - source: { studySubjectId: number | null; controlBatchId: number | null }, + source: RegistrationSource, specimenTypeId: number, collectionDate: string | null, ): Specimen | null { const sourceCondition = - source.studySubjectId !== null - ? eq(specimen.studySubjectId, source.studySubjectId) - : eq(specimen.controlBatchId, source.controlBatchId ?? -1) + 'subjectId' in source + ? eq(specimen.studySubjectId, source.subjectId) + : eq(specimen.controlBatchId, source.controlBatchId) const dateCondition = collectionDate == null ? isNull(specimen.collectionDate) : eq(specimen.collectionDate, collectionDate) return ( database @@ -75,14 +75,32 @@ function findSpecimen( ) } +/** Something a request may use only once. */ +type Claim = + | { kind: 'barcode'; containerType: 'micronix_tube' | 'cryovial_tube'; barcode: string } + | { kind: 'position'; gridType: GridCollectionType; collection: CollectionTarget; position: string } + | { kind: 'sublabel'; sheet: CollectionTarget; sublabel: string } + +function claimKey(claim: Claim): string { + switch (claim.kind) { + case 'barcode': + return `barcode:${claim.containerType}:${claim.barcode}` + case 'position': + return `position:${claim.gridType}:${targetKey(claim.collection)}:${claim.position}` + case 'sublabel': + return `sublabel:${targetKey(claim.sheet)}:${claim.sublabel}` + } +} + /** Per-request uniqueness: barcodes, Grid positions per Collection, sublabels per Sheet. */ class RequestClaims { - private readonly claims = new Set() + private readonly taken = new Set() /** Returns false when the claim was already taken earlier in the request. */ - take(claim: string): boolean { - if (this.claims.has(claim)) return false - this.claims.add(claim) + take(claim: Claim): boolean { + const key = claimKey(claim) + if (this.taken.has(key)) return false + this.taken.add(key) return true } } @@ -96,7 +114,7 @@ export async function planRegistration( const claims = new RequestClaims() const specimens: PlannedSpecimen[] = [] const specimenIndexByKey = new Map() - const entrySpecimen: number[] = [] + const entrySpecimen: Array = [] const containers: PlannedContainer[] = [] const useSpecimen = (key: string, make: () => PlannedSpecimen): number => { @@ -116,7 +134,7 @@ export async function planRegistration( const existing = database.select().from(specimen).where(eq(specimen.id, entry.specimenId)).get() if (!existing) { entryError('specimenId', 'Specimen not found') - entrySpecimen.push(-1) + entrySpecimen.push(undefined) continue } specimenTypeId = existing.specimenTypeId @@ -150,9 +168,9 @@ export async function planRegistration( if (sourceOk && specimenTypeId !== null && date.valid) { const typeId = specimenTypeId const collectionDate = date.normalized ?? null - const studySubjectId = 'subjectId' in entry.source ? entry.source.subjectId : null - const controlBatchId = 'controlBatchId' in entry.source ? entry.source.controlBatchId : null - const source = { studySubjectId, controlBatchId } + const source = entry.source + const studySubjectId = 'subjectId' in source ? source.subjectId : null + const controlBatchId = 'controlBatchId' in source ? source.controlBatchId : null const key = `${studySubjectId !== null ? `s${studySubjectId}` : `c${controlBatchId}`}:${typeId}:${collectionDate ?? ''}` entrySpecimen.push( useSpecimen(key, () => { @@ -168,7 +186,7 @@ export async function planRegistration( }), ) } else { - entrySpecimen.push(-1) + entrySpecimen.push(undefined) } } @@ -199,7 +217,7 @@ async function planContainer( const fields = validateContainerWriteFields(input) if (!fields.valid) { - return { errors: [{ field: 'container', message: fields.error ?? 'Invalid container data' }] } + return { errors: [{ field: fields.field, message: fields.error }] } } if (specimenTypeId !== null) { @@ -241,7 +259,7 @@ async function planContainer( .get() if (taken) problems.push({ field: 'sublabel', message: `Sublabel '${sublabel}' is already used on this sheet.` }) } - if (!claims.take(`sublabel:${targetKey(collection)}:${sublabel}`)) { + if (!claims.take({ kind: 'sublabel', sheet: collection, sublabel })) { problems.push({ field: 'sublabel', message: `Sublabel '${sublabel}' is used more than once on this sheet in your file.` }) } } @@ -265,7 +283,7 @@ async function planContainer( }) if (occupancy.occupied) problems.push({ field: 'position', message: occupiedGridPositionMessage(position, gridType) }) } - if (!claims.take(`position:${gridType}:${targetKey(collection)}:${position}`)) { + if (!claims.take({ kind: 'position', gridType, collection, position })) { problems.push({ field: 'position', message: `Position ${position} in this plate/box is used more than once in your file. Each position can only be used once.`, @@ -279,7 +297,7 @@ async function planContainer( const table = TUBE_TABLES[input.containerType] const taken = await database.select({ id: table.id }).from(table).where(eq(table.barcode, barcode)).get() if (taken) problems.push({ field: 'barcode', message: `Barcode '${barcode}' already exists. Use a different barcode.` }) - if (!claims.take(`barcode:${input.containerType}:${barcode}`)) { + if (!claims.take({ kind: 'barcode', containerType: input.containerType, barcode })) { problems.push({ field: 'barcode', message: `Barcode '${barcode}' is used more than once in your file. Each barcode must be unique.`, diff --git a/packages/api/src/lib/specimen-registration/write.ts b/packages/api/src/lib/specimen-registration/write.ts index f55e483..2dd14f4 100644 --- a/packages/api/src/lib/specimen-registration/write.ts +++ b/packages/api/src/lib/specimen-registration/write.ts @@ -93,6 +93,7 @@ export async function writeRegistration( for (let entryIndex = 0; entryIndex < plan.entrySpecimen.length; entryIndex++) { current = { entryIndex } const index = plan.entrySpecimen[entryIndex] + if (index === undefined) throw new Error('Registration plan has an entry without a specimen') const planned = plan.specimens[index] let row = specimenRows[index] let reused = true From 941bc0b4f15c5e41dcd1ca4cdd06e4e081370b9d Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:12:23 -0700 Subject: [PATCH 66/85] refactor(registration): rename only() to insertedRow(). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit only(rows, what) returned the row an INSERT … RETURNING gave back, or threw. The name now says that. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/lib/specimen-registration/write.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/api/src/lib/specimen-registration/write.ts b/packages/api/src/lib/specimen-registration/write.ts index 2dd14f4..1bb17ca 100644 --- a/packages/api/src/lib/specimen-registration/write.ts +++ b/packages/api/src/lib/specimen-registration/write.ts @@ -13,7 +13,8 @@ import { type RegistrationResult, } from './types' -function only(rows: T[], what: string): T { +/** The row an `INSERT … RETURNING` gave back; throws if it gave none. */ +function insertedRow(rows: T[], what: string): T { const row = rows.at(0) if (row === undefined) throw new Error(`Insert did not return ${what} row`) return row @@ -62,14 +63,14 @@ export async function writeRegistration( case 'cryovial_box': { const table = GRID_COLLECTION_TABLES[planned.type] const values = { name: planned.name, barcode: planned.barcode ?? null, locationId: planned.locationId, ...audit } - id = only(await database.insert(table).values(values).returning({ id: table.id }), planned.type).id + id = insertedRow(await database.insert(table).values(values).returning({ id: table.id }), planned.type).id break } case 'box': case 'bag': { const table = PARENT_COLLECTION_TABLES[planned.type] const values = { name: planned.name, locationId: planned.locationId, ...audit } - id = only(await database.insert(table).values(values).returning({ id: table.id }), planned.type).id + id = insertedRow(await database.insert(table).values(values).returning({ id: table.id }), planned.type).id break } case 'sheet': { @@ -80,7 +81,7 @@ export async function writeRegistration( bagId: planned.parentType === 'bag' ? parentId : null, ...audit, } - id = only(await database.insert(sheet).values(values).returning({ id: sheet.id }), 'sheet').id + id = insertedRow(await database.insert(sheet).values(values).returning({ id: sheet.id }), 'sheet').id break } } @@ -102,7 +103,7 @@ export async function writeRegistration( row = planned.existing } else { const { existing: _existing, ...values } = planned - row = only(await database.insert(specimen).values({ ...values, ...audit }).returning(), 'specimen') + row = insertedRow(await database.insert(specimen).values({ ...values, ...audit }).returning(), 'specimen') reused = false } specimenRows[index] = row @@ -114,7 +115,7 @@ export async function writeRegistration( current = { entryIndex: container.entryIndex, containerIndex: container.containerIndex } const entry = entries[container.entryIndex] const input = container.input - const { id } = only( + const { id } = insertedRow( await database .insert(storageContainer) .values({ From e7720476d003623f2779a5e0a87ed66ced8084cd Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:15:10 -0700 Subject: [PATCH 67/85] refactor(registration): word errors for any caller and let the CSV import say "your file". Registration errors named CSV columns (plate_name, box_name, sheet_name) and said a value repeated "in your file", though add-container, POST /specimens, /subjects/with-specimens, and the specimen entry form send JSON. The module now says "in this request" and describes missing names without column names. RegistrationOptions.inputName lets a caller name its input; the bulk combined import, which takes a CSV, passes "your file". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/__tests__/bulk-combined-import.test.ts | 9 +++++++++ .../__tests__/registration-orchestrator.test.ts | 2 +- packages/api/src/lib/bulk-combined-import.ts | 12 ++++++++---- .../__tests__/specimen-registration.test.ts | 14 ++++++++++++-- .../src/lib/specimen-registration/collections.ts | 13 ++++--------- .../api/src/lib/specimen-registration/index.ts | 2 +- packages/api/src/lib/specimen-registration/plan.ts | 12 ++++++++---- .../api/src/lib/specimen-registration/types.ts | 5 +++++ 8 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/api/src/lib/__tests__/bulk-combined-import.test.ts b/packages/api/src/lib/__tests__/bulk-combined-import.test.ts index 2323af6..b270815 100644 --- a/packages/api/src/lib/__tests__/bulk-combined-import.test.ts +++ b/packages/api/src/lib/__tests__/bulk-combined-import.test.ts @@ -352,6 +352,15 @@ describe('bulk-combined-import', () => { { name: 'a missing study on one subject', valid: false, subjects: () => [subject('A'), subject('B', undefined, 'NOPE')] }, ] + it('says a sublabel repeats in the file', async () => { + const validation = await validateBulkCombinedPayload(testDb, { + studyShortCode: 'ST1', + subjects: [subject('A', paperOn('Sheet-1', 's1')), subject('B', paperOn('Sheet-1', 's1'))], + }) + + expect(validation.errors.map((e) => e.message)).toEqual(["Sublabel 's1' is used more than once on this sheet in your file."]) + }) + for (const fixture of fixtures) { it(fixture.name, async () => { const payload = { studyShortCode: 'ST1', subjects: fixture.subjects() } diff --git a/packages/api/src/lib/__tests__/registration-orchestrator.test.ts b/packages/api/src/lib/__tests__/registration-orchestrator.test.ts index b55d066..19aa2a0 100644 --- a/packages/api/src/lib/__tests__/registration-orchestrator.test.ts +++ b/packages/api/src/lib/__tests__/registration-orchestrator.test.ts @@ -139,7 +139,7 @@ describe('registration-orchestrator', () => { ]) expect(result.valid).toBe(false) - expect(result.errors.some((e) => e.message.includes('used more than once in your file'))).toBe(true) + expect(result.errors.some((e) => e.message.includes('used more than once in this request'))).toBe(true) }) it('returns invalid when barcode already exists in database', async () => { diff --git a/packages/api/src/lib/bulk-combined-import.ts b/packages/api/src/lib/bulk-combined-import.ts index aacbfb3..b2eb4c4 100644 --- a/packages/api/src/lib/bulk-combined-import.ts +++ b/packages/api/src/lib/bulk-combined-import.ts @@ -90,6 +90,7 @@ async function registerSubjects( payloadStudyShortCode: string | undefined, subjects: BulkCombinedSubjectInput[], userId: number | undefined, + inputName?: string, ): Promise { const now = utcNow() const errors: BulkCombinedError[] = [] @@ -174,7 +175,7 @@ async function registerSubjects( let registration: RegistrationResult | null = null try { - registration = await registerSpecimens(database, entries, { userId }) + registration = await registerSpecimens(database, entries, { userId, inputName }) } catch (error) { if (!(error instanceof SpecimenRegistrationError)) throw error const mapped = error.errors.map((e) => ({ ...entryRefs[e.entryIndex], message: e.message })) @@ -240,6 +241,9 @@ export async function runOneSubjectWithSpecimens( } } +/** The bulk combined routes take a CSV import, so a value repeated in it is "used more than once in your file". */ +const CSV_INPUT_NAME = 'your file' + export interface BulkCombinedPayload { /** Default study for subjects that do not set their own studyShortCode. */ studyShortCode?: string @@ -265,7 +269,7 @@ export async function runBulkCombinedImport( const errors: Array<{ index: number; error: string }> = [] for (let i = 0; i < subjects.length; i++) { try { - const [one] = await withWriteTransaction(database, (tx) => registerSubjects(tx, studyShortCode, [subjects[i]], userId)) + const [one] = await withWriteTransaction(database, (tx) => registerSubjects(tx, studyShortCode, [subjects[i]], userId, CSV_INPUT_NAME)) results.push(one) } catch (error) { if (!(error instanceof BulkCombinedRejected)) throw error @@ -276,7 +280,7 @@ export async function runBulkCombinedImport( } try { - const results = await withWriteTransaction(database, (tx) => registerSubjects(tx, studyShortCode, subjects, userId)) + const results = await withWriteTransaction(database, (tx) => registerSubjects(tx, studyShortCode, subjects, userId, CSV_INPUT_NAME)) return { summary: sumSummaries(results), results } } catch (error) { if (!(error instanceof BulkCombinedRejected)) throw error @@ -297,7 +301,7 @@ export async function validateBulkCombinedPayload( ): Promise<{ valid: boolean; errors: BulkCombinedError[] }> { return withRolledBackTransaction(database, async (tx) => { try { - await registerSubjects(tx, payload.studyShortCode, payload.subjects, undefined) + await registerSubjects(tx, payload.studyShortCode, payload.subjects, undefined, CSV_INPUT_NAME) return { valid: true, errors: [] } } catch (error) { if (error instanceof BulkCombinedRejected) return { valid: false, errors: error.errors } diff --git a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts index 388a085..aa7e43a 100644 --- a/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts +++ b/packages/api/src/lib/specimen-registration/__tests__/specimen-registration.test.ts @@ -203,7 +203,7 @@ describe('Specimen registration', () => { ]) expect(error.errors).toEqual([ - expect.objectContaining({ entryIndex: 1, field: 'position', message: expect.stringContaining('more than once in your file') }), + expect.objectContaining({ entryIndex: 1, field: 'position', message: expect.stringContaining('more than once in this request') }), ]) }) @@ -289,11 +289,21 @@ describe('Specimen registration', () => { expect(error.errors.map((e) => [e.containerIndex, e.message])).toEqual([ [0, "Sublabel 's1' is already used on this sheet."], - [2, "Sublabel 's2' is used more than once on this sheet in your file."], + [2, "Sublabel 's2' is used more than once on this sheet in this request."], ]) }) }) + it('names the input a value repeats in as the caller asks', async () => { + const entries = [entry([tube('T1', { name: 'P1', position: 'A02' }), tube('T1', { name: 'P1', position: 'A03' })])] + + const error = await registerSpecimens(db, entries, { inputName: 'your file' }).catch((e: unknown) => e) + + expect((error as SpecimenRegistrationError).errors[0].message).toBe( + "Barcode 'T1' is used more than once in your file. Each barcode must be unique.", + ) + }) + it('keys a missing position to the position field', async () => { const error = await rejection([entry([tube('T1', { name: 'P1' })])]) diff --git a/packages/api/src/lib/specimen-registration/collections.ts b/packages/api/src/lib/specimen-registration/collections.ts index eac4cc0..699328b 100644 --- a/packages/api/src/lib/specimen-registration/collections.ts +++ b/packages/api/src/lib/specimen-registration/collections.ts @@ -175,12 +175,7 @@ export class CollectionPlanner { } const newName = name ?? barcode if (!newName) { - return { - error: - type === 'cryovial_box' - ? 'box_name or collection_barcode is required for cryovial_tube' - : 'plate_name or collection_barcode is required for micronix_tube', - } + return { error: `A new ${label.toLowerCase()} needs a name or a barcode.` } } const badLocation = locationError(collection.locationId, this.database) if (badLocation) return { error: badLocation } @@ -216,7 +211,7 @@ export class CollectionPlanner { } if (!parent.name) { - return { error: 'box_name or bag_name is required for paper' } + return { error: `A new ${label.toLowerCase()} needs a name.` } } if (parent.locationId == null) { return { error: `Collection (${type}) not found: ${parent.name}` } @@ -245,8 +240,8 @@ export class CollectionPlanner { return { target: { existingId: row.id } } } - if (!collection.name) return { error: 'sheet_name is required for paper' } - if (!collection.parent) return { error: 'box_name or bag_name is required for paper' } + if (!collection.name) return { error: 'A sheet needs an id or a name.' } + if (!collection.parent) return { error: 'A sheet named without an id needs its box or bag.' } const name = collection.name const parentType = collection.parent.type diff --git a/packages/api/src/lib/specimen-registration/index.ts b/packages/api/src/lib/specimen-registration/index.ts index 6646d22..0542b15 100644 --- a/packages/api/src/lib/specimen-registration/index.ts +++ b/packages/api/src/lib/specimen-registration/index.ts @@ -28,7 +28,7 @@ export async function registerSpecimens( options: RegistrationOptions = {}, ): Promise { return withWriteTransaction(database, async (db) => { - const { plan, errors } = await planRegistration(db, entries) + const { plan, errors } = await planRegistration(db, entries, options.inputName) if (errors.length > 0) throw new SpecimenRegistrationError(400, errors) return writeRegistration(db, plan, options.userId) }) diff --git a/packages/api/src/lib/specimen-registration/plan.ts b/packages/api/src/lib/specimen-registration/plan.ts index 814335a..19b3d82 100644 --- a/packages/api/src/lib/specimen-registration/plan.ts +++ b/packages/api/src/lib/specimen-registration/plan.ts @@ -96,6 +96,9 @@ function claimKey(claim: Claim): string { class RequestClaims { private readonly taken = new Set() + /** How messages name the input a claim repeats in (RegistrationOptions.inputName). */ + constructor(readonly inputName: string) {} + /** Returns false when the claim was already taken earlier in the request. */ take(claim: Claim): boolean { const key = claimKey(claim) @@ -108,10 +111,11 @@ class RequestClaims { export async function planRegistration( database: Database, entries: RegistrationEntry[], + inputName = 'this request', ): Promise<{ plan: RegistrationPlan; errors: RegistrationError[] }> { const errors: RegistrationError[] = [] const planner = new CollectionPlanner(database) - const claims = new RequestClaims() + const claims = new RequestClaims(inputName) const specimens: PlannedSpecimen[] = [] const specimenIndexByKey = new Map() const entrySpecimen: Array = [] @@ -260,7 +264,7 @@ async function planContainer( if (taken) problems.push({ field: 'sublabel', message: `Sublabel '${sublabel}' is already used on this sheet.` }) } if (!claims.take({ kind: 'sublabel', sheet: collection, sublabel })) { - problems.push({ field: 'sublabel', message: `Sublabel '${sublabel}' is used more than once on this sheet in your file.` }) + problems.push({ field: 'sublabel', message: `Sublabel '${sublabel}' is used more than once on this sheet in ${claims.inputName}.` }) } } } @@ -286,7 +290,7 @@ async function planContainer( if (!claims.take({ kind: 'position', gridType, collection, position })) { problems.push({ field: 'position', - message: `Position ${position} in this plate/box is used more than once in your file. Each position can only be used once.`, + message: `Position ${position} in this plate/box is used more than once in ${claims.inputName}. Each position can only be used once.`, }) } } @@ -300,7 +304,7 @@ async function planContainer( if (!claims.take({ kind: 'barcode', containerType: input.containerType, barcode })) { problems.push({ field: 'barcode', - message: `Barcode '${barcode}' is used more than once in your file. Each barcode must be unique.`, + message: `Barcode '${barcode}' is used more than once in ${claims.inputName}. Each barcode must be unique.`, }) } } diff --git a/packages/api/src/lib/specimen-registration/types.ts b/packages/api/src/lib/specimen-registration/types.ts index 42fb073..ea7b409 100644 --- a/packages/api/src/lib/specimen-registration/types.ts +++ b/packages/api/src/lib/specimen-registration/types.ts @@ -30,6 +30,11 @@ export type RegistrationEntry = export type RegistrationOptions = { userId?: number + /** + * How errors name the caller's input when a value repeats in it, for example 'your file' + * for a CSV import. Defaults to 'this request'. + */ + inputName?: string } /** Keyed to the input: entries[entryIndex].containers[containerIndex]. */ From 74b0df2df9bde73ca507a19b5bbffaf249ddd196 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:15:43 -0700 Subject: [PATCH 68/85] test(imports): check validate against per_subject imports and document the difference. The parity fixtures ran only full_file imports. Each fixture now also runs per_subject and checks the weaker property that mode allows: validate passes exactly when every subject imports, and every subject validate flags is one the import rejects. One more test pins the case where they differ: a subject that relies on a plate an earlier, failing subject would create is rejected by the import but not flagged by validate. validateBulkCombinedPayload and ADR 0010 describe this. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0010-specimen-registration.md | 1 + .../__tests__/bulk-combined-import.test.ts | 32 +++++++++++++++++++ packages/api/src/lib/bulk-combined-import.ts | 6 ++++ 3 files changed, 39 insertions(+) diff --git a/docs/adr/0010-specimen-registration.md b/docs/adr/0010-specimen-registration.md index edec8b0..ce7821c 100644 --- a/docs/adr/0010-specimen-registration.md +++ b/docs/adr/0010-specimen-registration.md @@ -34,4 +34,5 @@ We put **Specimen registration** (see [CONTEXT.md](../../CONTEXT.md)) behind one ## Consequences - Validate endpoints keep their wire shapes; each route maps module errors to its existing response. +- Bulk combined validate always checks the file as one `full_file` import. For a `per_subject` import that is deliberately stricter than the import: validate passes exactly when every subject would import, and each subject it flags is rejected by the import. The import can reject more, because a subject that relies on a collection an earlier, failing subject would have created fails once that subject is rolled back. - Migration is in slices, each deleting the code it replaces: bulk combined import, `/subjects/with-specimens`, and `/specimens/bulk`; `POST /specimens` and add-container; derivations and the derivation CSV; control batches. `/subjects/with-specimens` and `/specimens/bulk` moved into the first slice: the first shares bulk combined's code, and the second's validator was the last user of the old placement resolver (ADR 0006). diff --git a/packages/api/src/lib/__tests__/bulk-combined-import.test.ts b/packages/api/src/lib/__tests__/bulk-combined-import.test.ts index b270815..0866d65 100644 --- a/packages/api/src/lib/__tests__/bulk-combined-import.test.ts +++ b/packages/api/src/lib/__tests__/bulk-combined-import.test.ts @@ -352,6 +352,38 @@ describe('bulk-combined-import', () => { { name: 'a missing study on one subject', valid: false, subjects: () => [subject('A'), subject('B', undefined, 'NOPE')] }, ] + // per_subject commits each subject that passes, so the property is weaker: validate passes + // exactly when every subject imports, and each subject validate flags is one the import rejects. + for (const fixture of fixtures) { + it(`${fixture.name} (per_subject)`, async () => { + const payload = { studyShortCode: 'ST1', subjects: fixture.subjects() } + + const validation = await validateBulkCombinedPayload(testDb, payload) + const imported = await runBulkCombinedImport(testDb, { ...payload, atomicMode: 'per_subject' }, undefined) + + const flagged = new Set(validation.errors.map((e) => e.subjectIndex)) + const rejected = new Set((imported.errors ?? []).map((e) => e.index)) + expect(validation.valid).toBe(fixture.valid) + expect(rejected.size === 0).toBe(validation.valid) + expect([...flagged].every((index) => rejected.has(index))).toBe(true) + }) + } + + it('per_subject also rejects a subject that needed a collection from a subject that failed', async () => { + // B finds the new plate only because A, which fails, would create it. Validate checks the + // file as one import, so it flags A alone; per_subject drops A's plate and B fails too. + const subjects = () => [ + subject('A', tube('TAKEN', { name: 'NEW', locationId, position: 'A01' })), + subject('B', tube('T2', { name: 'NEW', position: 'A02' })), + ] + + const validation = await validateBulkCombinedPayload(testDb, { studyShortCode: 'ST1', subjects: subjects() }) + const imported = await runBulkCombinedImport(testDb, { studyShortCode: 'ST1', subjects: subjects(), atomicMode: 'per_subject' }, undefined) + + expect(validation.errors.map((e) => e.subjectIndex)).toEqual([0]) + expect((imported.errors ?? []).map((e) => e.index)).toEqual([0, 1]) + }) + it('says a sublabel repeats in the file', async () => { const validation = await validateBulkCombinedPayload(testDb, { studyShortCode: 'ST1', diff --git a/packages/api/src/lib/bulk-combined-import.ts b/packages/api/src/lib/bulk-combined-import.ts index b2eb4c4..4ae3204 100644 --- a/packages/api/src/lib/bulk-combined-import.ts +++ b/packages/api/src/lib/bulk-combined-import.ts @@ -294,6 +294,12 @@ export async function runBulkCombinedImport( /** * Check a bulk combined import by running it in full_file mode and rolling it back. * Reports every problem, whatever atomicMode the import will use. + * + * For a full_file import, validate passes exactly when the import succeeds. A per_subject import + * commits each subject that passes, so there validate passes exactly when every subject imports, + * and each subject it flags is one the import rejects. The import can reject more: a subject that + * relies on something an earlier, failing subject would have created (a new plate or sheet) fails + * too, while validate, checking the file as one import, flags only the earlier subject. */ export async function validateBulkCombinedPayload( database: Database, From e6b4815aa416f301c6c3a64af23fafbbda7ea94e Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Tue, 6 Oct 2026 17:25:01 -0700 Subject: [PATCH 69/85] docs: list the registration paths still migrating after slice 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CODING_STANDARDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index ef0c3c4..1276613 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -22,7 +22,7 @@ Paths are relative to `packages/api/`. ### Domain modules -- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import and `/subjects/with-specimens` have migrated. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, add-container and `/specimens/bulk`, which validate with `validateSpecimenData` or `validateBulkSpecimenRows` (`src/routes/specimens.ts`, `src/lib/registration-orchestrator.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). +- In a new write path, create Specimens and Containers with `registerSpecimens` from `src/lib/specimen-registration/` (ADR 0010). Bulk combined import, `/subjects/with-specimens` and `/specimens/bulk` have migrated, and every container write goes through `createContainerForSpecimen`, which wraps it. These paths still insert Specimens directly until their slice in ADR 0010 lands: `POST /specimens`, which validates with `validateSpecimenData` (`src/routes/specimens.ts`); derivations (`src/lib/derivations.ts`); and control batches (`src/lib/controls/batch-with-specimens.ts`). - In a new write path, validate a registration with `checkSpecimenRegistration`, or wrap the whole write in `withRolledBackTransaction` when the caller writes its own records first (ADR 0010). - Validate an inbound container with a `@sampledb/contract` schema (`containerWriteInputSchema`, `bulkCombinedContainerSchema` or `optionalContainerInputSchema`), not an endpoint-specific container Zod schema (ADR 0006). - Map a CSV row to `ContainerWriteInput` with `csvRowToContainerWriteInput` from `@sampledb/contract` (ADR 0006). The web bulk import and control-batch CSVs still map through `flatBulkContainerToWriteInput` and `flatControlBatchContainerToWriteInput` in `packages/web/src/lib/`. From 94a2fb6a89ee860bf9a8d5134873b9267169ba2d Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:31:29 -0700 Subject: [PATCH 70/85] docs: record Container selection in ADR 0011 and the glossary. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 4 +++ docs/adr/0011-container-selection.md | 54 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/adr/0011-container-selection.md diff --git a/CONTEXT.md b/CONTEXT.md index 0821b7e..99674c4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -137,6 +137,10 @@ _Avoid_: Well (as the domain term); treating a missing position as occupying a s ### Export +**Container selection**: +The set of **Containers**, with their **Specimens**, that a set of filters matches: a **Study**, a subject list, or a micronix barcode list, narrowed by specimen type, collection date, container type, **Tags**, **Location**, and when the Specimen or the Container was created. Selecting several Tags matches Containers carrying every one of them. A barcode list selects exactly those tubes and takes no other filters. **Container export** and the statistics dashboard both count and list what the selection matches. +_Avoid_: Query (implementation term); treating Tag selection as "any of these Tags"; an unqualified "created" date (say Specimen created or Container created) + **Container export**: Server-side export of operational **Container** records with specimen, source, and placement context, formatted using an **Export configuration**. Entry points include **Multi-Study Export** (page heading **Export Containers (Multi-Study)**), the Study export modal, and **Micronix Barcode Export**. _Avoid_: Using "bulk export" as the umbrella for all container exports; collection table snapshot export; using "sample" in export column headers diff --git a/docs/adr/0011-container-selection.md b/docs/adr/0011-container-selection.md new file mode 100644 index 0000000..364f376 --- /dev/null +++ b/docs/adr/0011-container-selection.md @@ -0,0 +1,54 @@ +# Container selection: one module decides which Containers a filter matches + +--- +status: accepted +--- + +**Container export** and the statistics dashboard each turned their filters into a set of **Containers** their own way, and the filters drifted apart. Export matched Containers carrying every selected **Tag** while `GET /containers` matched any of them, and statistics re-implemented the intersection inline. "Created" meant `storageContainer.created` in export and `specimen.created` in statistics. The container-type filter ran up to twice per export, once in a `filterContainerIdsByType` call and again inside `enrichContainerData`, and the GET export returned "No containers found" before applying it. Multi-study export queried one Study at a time and invented a `MULTI` study record to satisfy enrichment. In statistics, the branch for location-only and tag-only filters could never run. + +We put **Container selection** (see [CONTEXT.md](../../CONTEXT.md)) behind one module, `packages/api/src/lib/container-selection/`. + +## Interface + +- One function takes a selection and returns the matching Container rows, the matching Specimen rows, and each Container's type. It never enriches: callers pass the rows to `loadContainerReadViews` ([ADR 0009](./0009-container-read-view.md)). +- A selection is one of: + - a **Study**; + - a subject list of (Study, Subject ids, per-subject dates) entries spanning any number of Studies; + - a micronix barcode list; + - the whole database. +- The study and subject-list selections are subject-only. The whole-database selection takes a source type filter (**Subject** or **Control batch**). A barcode list selects exactly those tubes and takes no other filters. +- Every other selection takes the same filters: + - specimen types (a list); + - collection date range; + - Specimen created; + - Container created; + - container types; + - Tags; + - **Location**. +- The module takes resolved ids. Callers resolve study codes, subject names and location ids, and return their own 404s. An unknown id inside a selection matches nothing. +- Queries stay bounded: subqueries or chunked IN lists, never an IN list that grows with the upload. + +## Rules the module owns + +- **Tags**: a Container matches when it carries every selected Tag. This holds for every caller, `GET /containers` included. +- **Created**: Specimen created and Container created are separate filters. The routes keep their wire names: export's `created_from`/`created_to` map to Container created, and statistics' to Specimen created. +- **Container type**: applied once, here. The GET export's "No containers found" 404 now fires on the filtered result, so a Study whose Containers are all of other types returns 404 instead of an empty file. +- **Location**: the Location and every Location under it. +- **Collection dates**: a listed Subject with its own dates ignores the global collection-date range. Undated Subjects get the global range. Tolerance widens exact dates only. +- **Specimens**: with no Container-level filter (container type, Tag, Location, Container created), every matching Specimen is included, with or without Containers. With one, only Specimens that have a matching Container are included. Export ignores Specimens with no Container. + +## Considered options + +- **"Any selected Tag" for `GET /containers`**: rejected. One meaning per filter is the point. No web caller sends `tag_ids` there, so only external clients are affected. +- **Container created everywhere**: rejected for now. It would silently change what the statistics dashboard counts. Naming both filters keeps current behaviour and makes the difference visible. +- **Unifying the Container export `run*` executors**: still a non-goal (ADR 0009). This module sits under them and only selects. + +## Consequences + +- `build*Query` in `lib/export/query.ts`, `filterContainerIdsByType`, `lib/container-tag-filter.ts`, and the tag intersection and dead branch in `dashboard-stats.ts` are deleted as their callers migrate. +- `enrichContainerData` loses its type filter and its `_specimens`, `study` and `subjectToStudyMap` parameters. It takes study fields from the Source's study chain ([ADR 0007](./0007-specimen-source-resolution.md)). The `MULTI` study record survives only in JSON export metadata. +- Callers build the **Export summary** from the module's output, grouping by Study for multi-study export. +- Migration is in slices, each deleting the code it replaces: the module with Container export, then dashboard statistics, then the `GET /containers` tag rule. +- The first slice adds a `CODING_STANDARDS.md` rule pointing new export and statistics code at this module, and lists the paths that haven't migrated yet. +- The statistics slice relabels the dashboard's "Created" filter as "Specimen created". The wire is unchanged. +- The module's tests cover each filter for every selection kind against the in-memory database. Statistics and export route tests keep aggregation and envelope checks. From cb80630c5dc2eaff1091446f084f68c1e1d64236 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:45:14 -0700 Subject: [PATCH 71/85] feat(api): add the Container selection module from ADR 0011. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/container-selection.test.ts | 562 ++++++++++++++++++ .../src/lib/container-selection/conditions.ts | 116 ++++ .../api/src/lib/container-selection/index.ts | 100 ++++ .../api/src/lib/container-selection/scopes.ts | 95 +++ .../api/src/lib/container-selection/types.ts | 72 +++ 5 files changed, 945 insertions(+) create mode 100644 packages/api/src/lib/container-selection/__tests__/container-selection.test.ts create mode 100644 packages/api/src/lib/container-selection/conditions.ts create mode 100644 packages/api/src/lib/container-selection/index.ts create mode 100644 packages/api/src/lib/container-selection/scopes.ts create mode 100644 packages/api/src/lib/container-selection/types.ts diff --git a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts new file mode 100644 index 0000000..05d3353 --- /dev/null +++ b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts @@ -0,0 +1,562 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { count, eq } from 'drizzle-orm' +import type { Database as SQLiteDatabase } from 'bun:sqlite' +import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' +import { SQL_IN_CHUNK_SIZE } from '../../sql-helpers' +import { + createTestControlBatch, + createTestControlDefinition, + createTestCryovialBox, + createTestCryovialTube, + createTestLocation, + createTestMicronixPlate, + createTestMicronixTube, + createTestSpecimen, + createTestSpecimenType, + createTestStorageContainer, + createTestStorageType, + createTestStaticWell, + createTestPaper, + createTestSheet, + createTestBox, + createTestBag, + createTestStudy, + createTestStudySubject, + createTestTag, + createTestUnit, +} from '../../../__tests__/helpers/factories' +import type { Database } from '../../../db/client' +import { + specimen as specimenTable, + storageContainer as storageContainerTable, + storageContainerTag, +} from '../../../db/schema' +import { containerHasAllTags, selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '..' + +describe('selectContainers', () => { + let db: Database + let sqlite: SQLiteDatabase + let unitId: number + let specimenTypeId: number + let plateId: number + let cryovialBoxId: number + let storageTypeId: string + let barcodeCount = 0 + + beforeEach(async () => { + const setup = await setupTestDatabase() + db = setup.db + sqlite = setup.sqlite + unitId = (await createTestUnit(db, { symbol: 'uL', name: 'microliter', category: 'volume' })).id as number + specimenTypeId = (await createTestSpecimenType(db, { name: 'Blood' })).id + storageTypeId = String((await createTestStorageType(db, { name: 'Freezer -80' })).id) + const freezer = await createTestLocation(db, { name: 'Freezer', storageTypeId, canContainCollections: true }) + plateId = (await createTestMicronixPlate(db, { name: 'Plate', locationId: freezer.id })).id + cryovialBoxId = (await createTestCryovialBox(db, { name: 'Box', locationId: freezer.id })).id + }) + + afterEach(() => cleanupTestDatabase(sqlite)) + + async function seedSubject(studyId: number, name: string) { + return createTestStudySubject(db, { studyId, name }) + } + + async function seedSpecimen( + source: { studySubjectId: number } | { controlBatchId: number }, + fields: { collectionDate?: string; created?: string; specimenTypeId?: number } = {}, + ) { + const row = await createTestSpecimen(db, fields.specimenTypeId ?? specimenTypeId, source) + await db + .update(specimenTable) + .set({ + collectionDate: fields.collectionDate ?? null, + ...(fields.created ? { created: fields.created } : {}), + }) + .where(eq(specimenTable.id, row.id)) + return row + } + + type ContainerKind = 'micronix_tube' | 'cryovial_tube' | 'untyped' + + async function seedContainer( + specimenId: number, + options: { kind?: ContainerKind; created?: string; collectionId?: number; barcode?: string } = {}, + ) { + const container = await createTestStorageContainer(db, { specimenId, unitId }) + if (options.created) { + await db + .update(storageContainerTable) + .set({ created: options.created }) + .where(eq(storageContainerTable.id, container.id)) + } + const kind = options.kind ?? 'micronix_tube' + if (kind === 'micronix_tube') { + barcodeCount += 1 + await createTestMicronixTube(db, { + containerId: container.id, + collectionId: options.collectionId ?? plateId, + barcode: options.barcode ?? `MX${barcodeCount}`, + }) + } else if (kind === 'cryovial_tube') { + await createTestCryovialTube(db, { containerId: container.id, collectionId: options.collectionId ?? cryovialBoxId }) + } + return container + } + + async function tag(containerId: number, ...tagIds: number[]) { + await db.insert(storageContainerTag).values(tagIds.map((tagId) => ({ storageContainerId: containerId, tagId }))) + } + + const ids = (rows: Array<{ id: number }>) => rows.map((row) => row.id) + + describe('study selection', () => { + it("returns the Study's Containers, their Specimens and each Container's type", async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const other = await createTestStudy(db, { title: 'Other', shortCode: 'S2' }) + const subject = await seedSubject(study.id, 'P1') + const otherSubject = await seedSubject(other.id, 'Q1') + const specimen = await seedSpecimen({ studySubjectId: subject.id }) + const tube = await seedContainer(specimen.id, { kind: 'micronix_tube' }) + const vial = await seedContainer(specimen.id, { kind: 'cryovial_tube' }) + await seedContainer((await seedSpecimen({ studySubjectId: otherSubject.id })).id) + + const result = await selectContainers(db, { kind: 'study', studyId: study.id }) + + expect(ids(result.containers)).toEqual([tube.id, vial.id]) + expect(ids(result.specimens)).toEqual([specimen.id]) + expect(result.containerTypes).toEqual( + new Map([ + [tube.id, 'micronix_tube'], + [vial.id, 'cryovial_tube'], + ]), + ) + }) + + it('includes Specimens without Containers when no Container-level filter is set', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const stored = await seedSpecimen({ studySubjectId: subject.id }) + await seedContainer(stored.id) + const unstored = await seedSpecimen({ studySubjectId: subject.id }) + + const result = await selectContainers(db, { kind: 'study', studyId: study.id }) + + expect(ids(result.specimens)).toEqual([stored.id, unstored.id]) + expect(result.containers).toHaveLength(1) + }) + + it('matches nothing for an unknown Study id', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + await seedContainer((await seedSpecimen({ studySubjectId: (await seedSubject(study.id, 'P1')).id })).id) + + const result = await selectContainers(db, { kind: 'study', studyId: study.id + 999 }) + + expect(result).toEqual({ containers: [], specimens: [], containerTypes: new Map() }) + }) + }) + + const selectionKinds: Array< + [string, (studyId: number, subjectId: number, filters: ContainerSelectionFilters) => ContainerSelection] + > = [ + ['study', (studyId, _subjectId, filters) => ({ kind: 'study', studyId, filters })], + ['subject-list', (studyId, subjectId, filters) => ({ kind: 'subjects', subjects: [{ studyId, subjectId }], filters })], + ['whole-database', (_studyId, _subjectId, filters) => ({ kind: 'database', filters })], + ] + + describe.each(selectionKinds)('filters on a %s selection', (_kind, select) => { + it('keeps Specimens of the listed specimen types', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const plasmaTypeId = (await createTestSpecimenType(db, { name: 'Plasma' })).id + const dbsTypeId = (await createTestSpecimenType(db, { name: 'DBS' })).id + const blood = await seedSpecimen({ studySubjectId: subject.id }) + const plasma = await seedSpecimen({ studySubjectId: subject.id }, { specimenTypeId: plasmaTypeId }) + await seedSpecimen({ studySubjectId: subject.id }, { specimenTypeId: dbsTypeId }) + const bloodTube = await seedContainer(blood.id) + const plasmaTube = await seedContainer(plasma.id) + + const result = await selectContainers(db, select(study.id, subject.id, { specimenTypeIds: [specimenTypeId, plasmaTypeId] })) + + expect(ids(result.specimens)).toEqual([blood.id, plasma.id]) + expect(ids(result.containers)).toEqual([bloodTube.id, plasmaTube.id]) + }) + + it('keeps Specimens collected inside the collection date range, both ends inclusive', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-01-31' }) + const first = await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-02-01' }) + const last = await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-02-29' }) + await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-03-01' }) + await seedSpecimen({ studySubjectId: subject.id }) + + const result = await selectContainers(db, select(study.id, subject.id, { collectionDate: { from: '2024-02-01', to: '2024-02-29' } })) + + expect(ids(result.specimens)).toEqual([first.id, last.id]) + }) + + it('filters Specimen created on the Specimen, not on its Containers', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const january = await seedSpecimen({ studySubjectId: subject.id }, { created: '2024-01-15 10:00:00' }) + const januaryTube = await seedContainer(january.id, { created: '2024-06-01 09:00:00' }) + const june = await seedSpecimen({ studySubjectId: subject.id }, { created: '2024-06-01 09:00:00' }) + await seedContainer(june.id, { created: '2024-01-15 10:00:00' }) + + const result = await selectContainers(db, select(study.id, subject.id, { specimenCreated: { from: '2024-01-01', to: '2024-01-31' } })) + + expect(ids(result.specimens)).toEqual([january.id]) + expect(ids(result.containers)).toEqual([januaryTube.id]) + }) + + it('filters Container created on the Container, keeping only Specimens with a matching Container', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const january = await seedSpecimen({ studySubjectId: subject.id }, { created: '2024-06-01 09:00:00' }) + const januaryTube = await seedContainer(january.id, { created: '2024-01-31 23:59:59' }) + await seedContainer(january.id, { created: '2024-02-01 00:00:00' }) + const june = await seedSpecimen({ studySubjectId: subject.id }, { created: '2024-01-15 10:00:00' }) + await seedContainer(june.id, { created: '2024-06-01 09:00:00' }) + await seedSpecimen({ studySubjectId: subject.id }, { created: '2024-01-15 10:00:00' }) + + const result = await selectContainers(db, select(study.id, subject.id, { containerCreated: { from: '2024-01-01', to: '2024-01-31' } })) + + expect(ids(result.containers)).toEqual([januaryTube.id]) + expect(ids(result.specimens)).toEqual([january.id]) + }) + + it('keeps Containers of the listed container types, and only their Specimens', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const both = await seedSpecimen({ studySubjectId: subject.id }) + await seedContainer(both.id, { kind: 'micronix_tube' }) + const vial = await seedContainer(both.id, { kind: 'cryovial_tube' }) + const tubeOnly = await seedSpecimen({ studySubjectId: subject.id }) + await seedContainer(tubeOnly.id, { kind: 'micronix_tube' }) + const untypedOnly = await seedSpecimen({ studySubjectId: subject.id }) + const untyped = await seedContainer(untypedOnly.id, { kind: 'untyped' }) + + const vials = await selectContainers(db, select(study.id, subject.id, { containerTypes: ['cryovial_tube', 'not_a_type'] })) + const unknown = await selectContainers(db, select(study.id, subject.id, { containerTypes: ['unknown'] })) + + expect(ids(vials.containers)).toEqual([vial.id]) + expect(ids(vials.specimens)).toEqual([both.id]) + expect(vials.containerTypes).toEqual(new Map([[vial.id, 'cryovial_tube']])) + expect(ids(unknown.containers)).toEqual([untyped.id]) + expect(ids(unknown.specimens)).toEqual([untypedOnly.id]) + }) + + it('matches nothing when every listed container type is unknown', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + await seedContainer((await seedSpecimen({ studySubjectId: subject.id })).id) + + const result = await selectContainers(db, select(study.id, subject.id, { containerTypes: ['not_a_type'] })) + + expect(result).toEqual({ containers: [], specimens: [], containerTypes: new Map() }) + }) + + it('keeps Containers carrying every selected Tag, and only their Specimens', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const red = (await createTestTag(db, { name: 'red' })).id + const blue = (await createTestTag(db, { name: 'blue' })).id + const both = await seedSpecimen({ studySubjectId: subject.id }) + const bothTube = await seedContainer(both.id) + await tag(bothTube.id, red, blue) + const redOnly = await seedSpecimen({ studySubjectId: subject.id }) + await tag((await seedContainer(redOnly.id)).id, red) + + const result = await selectContainers(db, select(study.id, subject.id, { tagIds: [red, blue] })) + + expect(ids(result.containers)).toEqual([bothTube.id]) + expect(ids(result.specimens)).toEqual([both.id]) + }) + + it('matches nothing when a selected Tag id is unknown', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const red = (await createTestTag(db, { name: 'red' })).id + const subject = await seedSubject(study.id, 'P1') + const specimen = await seedSpecimen({ studySubjectId: subject.id }) + await tag((await seedContainer(specimen.id)).id, red) + + const result = await selectContainers(db, select(study.id, subject.id, { tagIds: [red, red + 999] })) + + expect(result.containers).toEqual([]) + }) + + it('keeps Containers stored in the Location or any Location under it, and only their Specimens', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const room = await createTestLocation(db, { name: 'Room', storageTypeId }) + const rack = await createTestLocation(db, { name: 'Rack', parentId: room.id, canContainCollections: true }) + const shelf = await createTestLocation(db, { name: 'Shelf', parentId: rack.id, canContainCollections: true }) + const shelfPlate = (await createTestMicronixPlate(db, { name: 'Shelf plate', locationId: shelf.id })).id + const rackBox = (await createTestCryovialBox(db, { name: 'Rack box', locationId: rack.id })).id + const shelfSheet = ( + await createTestSheet(db, { name: 'Sheet A', boxId: (await createTestBox(db, { name: 'Card box', locationId: shelf.id })).id }) + ).id + const rackSheet = ( + await createTestSheet(db, { name: 'Sheet B', bagId: (await createTestBag(db, { name: 'Bag', locationId: rack.id })).id }) + ).id + + const specimen = await seedSpecimen({ studySubjectId: subject.id }) + const tube = await seedContainer(specimen.id, { collectionId: shelfPlate }) + const vial = await seedContainer(specimen.id, { kind: 'cryovial_tube', collectionId: rackBox }) + const well = await seedContainer(specimen.id, { kind: 'untyped' }) + await createTestStaticWell(db, { containerId: well.id, collectionId: shelfPlate }) + const boxedPaper = await seedContainer(specimen.id, { kind: 'untyped' }) + await createTestPaper(db, { containerId: boxedPaper.id, sheetId: shelfSheet }) + const baggedPaper = await seedContainer(specimen.id, { kind: 'untyped' }) + await createTestPaper(db, { containerId: baggedPaper.id, sheetId: rackSheet }) + await seedContainer(specimen.id) + await seedContainer(specimen.id, { kind: 'untyped' }) + const elsewhere = await seedSpecimen({ studySubjectId: subject.id }) + await seedContainer(elsewhere.id) + + const underRack = await selectContainers(db, select(study.id, subject.id, { locationId: rack.id })) + const underShelf = await selectContainers(db, select(study.id, subject.id, { locationId: shelf.id })) + const unknown = await selectContainers(db, select(study.id, subject.id, { locationId: 99999 })) + + expect(ids(underRack.containers)).toEqual([tube.id, vial.id, well.id, boxedPaper.id, baggedPaper.id]) + expect(ids(underRack.specimens)).toEqual([specimen.id]) + expect(ids(underShelf.containers)).toEqual([tube.id, well.id, boxedPaper.id]) + expect(unknown).toEqual({ containers: [], specimens: [], containerTypes: new Map() }) + }) + }) + + describe('subject-list selection', () => { + it('selects the listed Subjects across Studies, and nothing for a Subject listed under the wrong Study or an unknown id', async () => { + const first = await createTestStudy(db, { title: 'First', shortCode: 'S1' }) + const second = await createTestStudy(db, { title: 'Second', shortCode: 'S2' }) + const p1 = await seedSubject(first.id, 'P1') + const p2 = await seedSubject(first.id, 'P2') + const q1 = await seedSubject(second.id, 'Q1') + const q2 = await seedSubject(second.id, 'Q2') + const p1Specimen = await seedSpecimen({ studySubjectId: p1.id }) + const p1Tube = await seedContainer(p1Specimen.id) + await seedContainer((await seedSpecimen({ studySubjectId: p2.id })).id) + const q1Specimen = await seedSpecimen({ studySubjectId: q1.id }) + const q1Tube = await seedContainer(q1Specimen.id) + await seedContainer((await seedSpecimen({ studySubjectId: q2.id })).id) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: [ + { studyId: first.id, subjectId: p1.id }, + { studyId: second.id, subjectId: q1.id }, + { studyId: first.id, subjectId: q2.id }, + { studyId: first.id, subjectId: 99999 }, + ], + }) + + expect(ids(result.specimens)).toEqual([p1Specimen.id, q1Specimen.id]) + expect(ids(result.containers)).toEqual([p1Tube.id, q1Tube.id]) + }) + + it("uses a Subject's own dates instead of the global range, and the global range for undated Subjects", async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const dated = await seedSubject(study.id, 'P1') + const undated = await seedSubject(study.id, 'P2') + const visitOne = await seedSpecimen({ studySubjectId: dated.id }, { collectionDate: '2023-03-01' }) + const visitTwo = await seedSpecimen({ studySubjectId: dated.id }, { collectionDate: '2023-06-10' }) + await seedSpecimen({ studySubjectId: dated.id }, { collectionDate: '2024-01-15' }) + const inRange = await seedSpecimen({ studySubjectId: undated.id }, { collectionDate: '2024-01-15' }) + await seedSpecimen({ studySubjectId: undated.id }, { collectionDate: '2023-03-01' }) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: [ + { studyId: study.id, subjectId: dated.id, dates: [{ exact: '2023-03-01' }, { from: '2023-06-01', to: '2023-06-30' }] }, + { studyId: study.id, subjectId: undated.id }, + ], + filters: { collectionDate: { from: '2024-01-01', to: '2024-12-31' } }, + }) + + expect(ids(result.specimens)).toEqual([visitOne.id, visitTwo.id, inRange.id]) + }) + + it('widens exact per-Subject dates by the tolerance, but not date ranges', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const exact = await seedSubject(study.id, 'P1') + const ranged = await seedSubject(study.id, 'P2') + const twoDaysBefore = await seedSpecimen({ studySubjectId: exact.id }, { collectionDate: '2024-02-28' }) + const twoDaysAfter = await seedSpecimen({ studySubjectId: exact.id }, { collectionDate: '2024-03-03' }) + await seedSpecimen({ studySubjectId: exact.id }, { collectionDate: '2024-03-04' }) + const rangeEnd = await seedSpecimen({ studySubjectId: ranged.id }, { collectionDate: '2024-03-31' }) + await seedSpecimen({ studySubjectId: ranged.id }, { collectionDate: '2024-04-01' }) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: [ + { studyId: study.id, subjectId: exact.id, dates: [{ exact: '2024-03-01' }] }, + { studyId: study.id, subjectId: ranged.id, dates: [{ from: '2024-03-01', to: '2024-03-31' }] }, + ], + dateTolerance: 2, + }) + + expect(ids(result.specimens)).toEqual([twoDaysBefore.id, twoDaysAfter.id, rangeEnd.id]) + }) + + it('treats a Subject listed both with and without dates as undated', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const march = await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-03-01' }) + const may = await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-05-01' }) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: [ + { studyId: study.id, subjectId: subject.id, dates: [{ exact: '2024-03-01' }] }, + { studyId: study.id, subjectId: subject.id }, + ], + }) + + expect(ids(result.specimens)).toEqual([march.id, may.id]) + }) + + it('selects nothing for an empty subject list', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + await seedContainer((await seedSpecimen({ studySubjectId: (await seedSubject(study.id, 'P1')).id })).id) + + const result = await selectContainers(db, { kind: 'subjects', subjects: [] }) + + expect(result).toEqual({ containers: [], specimens: [], containerTypes: new Map() }) + }) + + it('selects every listed Subject when the list is longer than one IN chunk', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const firstSubject = await seedSubject(study.id, 'P1') + const lastSubject = await seedSubject(study.id, 'P2') + const first = await seedSpecimen({ studySubjectId: firstSubject.id }, { collectionDate: '2024-01-01' }) + const last = await seedSpecimen({ studySubjectId: lastSubject.id }, { collectionDate: '2024-01-01' }) + const unknownSubjects = Array.from({ length: SQL_IN_CHUNK_SIZE }, (_, i) => ({ + studyId: study.id, + subjectId: 100000 + i, + dates: [{ exact: '2024-01-01' }], + })) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: [ + { studyId: study.id, subjectId: firstSubject.id, dates: [{ exact: '2024-01-01' }] }, + ...unknownSubjects, + { studyId: study.id, subjectId: lastSubject.id }, + ], + }) + + expect(ids(result.specimens)).toEqual([first.id, last.id]) + }) + }) + + describe('whole-database selection', () => { + async function seedSources() { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subjectSpecimen = await seedSpecimen({ studySubjectId: (await seedSubject(study.id, 'P1')).id }) + const definition = await createTestControlDefinition(db) + const batch = await createTestControlBatch(db, definition.id) + const controlSpecimen = await seedSpecimen({ controlBatchId: batch.id }) + return { subjectSpecimen, controlSpecimen } + } + + it('selects Specimens of Subjects and of Control batches', async () => { + const { subjectSpecimen, controlSpecimen } = await seedSources() + + const result = await selectContainers(db, { kind: 'database' }) + + expect(ids(result.specimens)).toEqual([subjectSpecimen.id, controlSpecimen.id]) + }) + + it('narrows to one source type', async () => { + const { subjectSpecimen, controlSpecimen } = await seedSources() + + const subjects = await selectContainers(db, { kind: 'database', sourceType: 'subject' }) + const controls = await selectContainers(db, { kind: 'database', sourceType: 'control' }) + + expect(ids(subjects.specimens)).toEqual([subjectSpecimen.id]) + expect(ids(controls.specimens)).toEqual([controlSpecimen.id]) + }) + }) + + describe('micronix barcode selection', () => { + it("selects exactly the listed tubes and their Specimens, whatever the Specimen's Source", async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subjectSpecimen = await seedSpecimen({ studySubjectId: (await seedSubject(study.id, 'P1')).id }) + const listed = await seedContainer(subjectSpecimen.id, { barcode: 'A1' }) + await seedContainer(subjectSpecimen.id, { barcode: 'A2' }) + await seedContainer(subjectSpecimen.id, { kind: 'cryovial_tube' }) + const batch = await createTestControlBatch(db, (await createTestControlDefinition(db)).id) + const controlSpecimen = await seedSpecimen({ controlBatchId: batch.id }) + const control = await seedContainer(controlSpecimen.id, { barcode: 'C1' }) + await seedSpecimen({ studySubjectId: (await seedSubject(study.id, 'P2')).id }) + + const result = await selectContainers(db, { kind: 'micronixBarcodes', barcodes: ['A1', 'C1', 'NOPE', 'A1'] }) + + expect(ids(result.containers)).toEqual([listed.id, control.id]) + expect(ids(result.specimens)).toEqual([subjectSpecimen.id, controlSpecimen.id]) + expect(result.containerTypes).toEqual( + new Map([ + [listed.id, 'micronix_tube'], + [control.id, 'micronix_tube'], + ]), + ) + }) + + it('selects every listed tube when the list is longer than one IN chunk', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const specimen = await seedSpecimen({ studySubjectId: (await seedSubject(study.id, 'P1')).id }) + const first = await seedContainer(specimen.id, { barcode: 'FIRST' }) + const last = await seedContainer(specimen.id, { barcode: 'LAST' }) + const unknown = Array.from({ length: SQL_IN_CHUNK_SIZE }, (_, i) => `UNKNOWN${i}`) + + const result = await selectContainers(db, { kind: 'micronixBarcodes', barcodes: ['FIRST', ...unknown, 'LAST'] }) + + expect(ids(result.containers)).toEqual([first.id, last.id]) + expect(ids(result.specimens)).toEqual([specimen.id]) + }) + }) +}) + +describe('containerHasAllTags', () => { + let db: Database + let sqlite: SQLiteDatabase + + beforeEach(async () => { + const setup = await setupTestDatabase() + db = setup.db + sqlite = setup.sqlite + }) + + afterEach(() => cleanupTestDatabase(sqlite)) + + it('filters and counts a paginated Container query by every selected Tag', async () => { + const red = (await createTestTag(db, { name: 'red' })).id + const blue = (await createTestTag(db, { name: 'blue' })).id + const unitId = (await createTestUnit(db, { symbol: 'uL', name: 'microliter', category: 'volume' })).id as number + const specimenTypeId = (await createTestSpecimenType(db, { name: 'Blood' })).id + const specimenId = (await createTestSpecimen(db, specimenTypeId)).id + const containers = [] + for (let i = 0; i < 4; i++) containers.push(await createTestStorageContainer(db, { specimenId, unitId })) + const links = [ + { storageContainerId: containers[0].id, tagId: red }, + { storageContainerId: containers[0].id, tagId: blue }, + { storageContainerId: containers[1].id, tagId: red }, + { storageContainerId: containers[2].id, tagId: red }, + { storageContainerId: containers[2].id, tagId: blue }, + ] + await db.insert(storageContainerTag).values(links) + + const where = containerHasAllTags([red, blue, red]) + const page = await db + .select() + .from(storageContainerTable) + .where(where) + .orderBy(storageContainerTable.id) + .limit(1) + .offset(1) + const [{ total }] = await db.select({ total: count() }).from(storageContainerTable).where(where) + + expect(page.map((row) => row.id)).toEqual([containers[2].id]) + expect(total).toBe(2) + }) +}) diff --git a/packages/api/src/lib/container-selection/conditions.ts b/packages/api/src/lib/container-selection/conditions.ts new file mode 100644 index 0000000..7af07de --- /dev/null +++ b/packages/api/src/lib/container-selection/conditions.ts @@ -0,0 +1,116 @@ +import { count, gte, inArray, sql, type SQL } from 'drizzle-orm' +import type { SQLiteColumn } from 'drizzle-orm/sqlite-core' +import { + bag, + box, + cryovialBox, + cryovialTube, + location, + micronixPlate, + micronixTube, + paper, + sheet, + specimen, + staticWell, + storageContainer, + storageContainerTag, +} from '../../db/schema' +import type { ContainerSubtype } from '../container-placement' +import { dateToUpperBound } from '../sql-helpers' +import type { ContainerSelectionFilters, DateRange } from './types' + +/** + * A Container's type, from the subtype table holding it, in the precedence + * `resolveContainerPlacements` uses. 'unknown' when no subtype table holds it. + */ +export const containerTypeSql = sql`CASE + WHEN EXISTS (SELECT 1 FROM ${micronixTube} WHERE ${micronixTube.id} = ${storageContainer.id}) THEN 'micronix_tube' + WHEN EXISTS (SELECT 1 FROM ${cryovialTube} WHERE ${cryovialTube.id} = ${storageContainer.id}) THEN 'cryovial_tube' + WHEN EXISTS (SELECT 1 FROM ${paper} WHERE ${paper.id} = ${storageContainer.id}) THEN 'paper' + WHEN EXISTS (SELECT 1 FROM ${staticWell} WHERE ${staticWell.id} = ${storageContainer.id}) THEN 'static_well' + ELSE 'unknown' +END` + +/** Conditions for an inclusive date range on a date or timestamp column. */ +export function dateRangeConditions(column: SQLiteColumn, range: DateRange | undefined): SQL[] { + return [ + ...(range?.from ? [gte(column, range.from)] : []), + ...(range?.to ? [dateToUpperBound(column, range.to)] : []), + ] +} + +/** + * Conditions on the Specimen row other than its collection date, which depends on the + * selection: specimen types and Specimen created. + */ +export function specimenFilterConditions(filters: ContainerSelectionFilters): SQL[] { + const conditions: SQL[] = [] + if (filters.specimenTypeIds && filters.specimenTypeIds.length > 0) { + conditions.push(inArray(specimen.specimenTypeId, [...new Set(filters.specimenTypeIds)])) + } + conditions.push(...dateRangeConditions(specimen.created, filters.specimenCreated)) + return conditions +} + +/** + * Container-level filters: Container created, container types, Tags and Location. With any + * of them set, only Specimens with a matching Container are selected. + */ +export function containerFilterConditions(filters: ContainerSelectionFilters): SQL[] { + const conditions = [...dateRangeConditions(storageContainer.created, filters.containerCreated)] + if (filters.containerTypes && filters.containerTypes.length > 0) { + conditions.push(inArray(containerTypeSql, [...new Set(filters.containerTypes)])) + } + const tags = containerHasAllTags(filters.tagIds ?? []) + if (tags) conditions.push(tags) + if (filters.locationId !== undefined) conditions.push(containerAtLocation(filters.locationId)) + return conditions +} + +/** + * Containers stored in a Location or any Location under it: tubes and static wells on + * micronix plates, tubes in cryovial boxes, and papers on sheets in boxes or bags there. + */ +function containerAtLocation(locationId: number): SQL { + const here = sql`SELECT id FROM selected_location` + return sql`${storageContainer.id} IN ( + WITH RECURSIVE selected_location(id) AS ( + SELECT ${location.id} FROM ${location} WHERE ${location.id} = ${locationId} + UNION + SELECT child.id FROM ${location} AS child JOIN selected_location ON child.parent_id = selected_location.id + ) + SELECT ${micronixTube.id} FROM ${micronixTube} + JOIN ${micronixPlate} ON ${micronixPlate.id} = ${micronixTube.collectionId} + WHERE ${micronixPlate.locationId} IN (${here}) + UNION ALL + SELECT ${staticWell.id} FROM ${staticWell} + JOIN ${micronixPlate} ON ${micronixPlate.id} = ${staticWell.collectionId} + WHERE ${micronixPlate.locationId} IN (${here}) + UNION ALL + SELECT ${cryovialTube.id} FROM ${cryovialTube} + JOIN ${cryovialBox} ON ${cryovialBox.id} = ${cryovialTube.collectionId} + WHERE ${cryovialBox.locationId} IN (${here}) + UNION ALL + SELECT ${paper.id} FROM ${paper} + JOIN ${sheet} ON ${sheet.id} = ${paper.sheetId} + LEFT JOIN ${box} ON ${box.id} = ${sheet.boxId} + LEFT JOIN ${bag} ON ${bag.id} = ${sheet.bagId} + WHERE coalesce(${box.locationId}, ${bag.locationId}) IN (${here}) + )` +} + +/** + * The Tag rule (ADR 0011): a Container matches when it carries every selected Tag. A + * condition on `storage_container`, so a caller can page and count in SQL. Undefined when no + * Tag is selected; an unknown Tag id matches nothing. + */ +export function containerHasAllTags(tagIds: readonly number[]): SQL | undefined { + const unique = [...new Set(tagIds)] + if (unique.length === 0) return undefined + return sql`${storageContainer.id} IN ( + SELECT ${storageContainerTag.storageContainerId} FROM ${storageContainerTag} + WHERE ${inArray(storageContainerTag.tagId, unique)} + GROUP BY ${storageContainerTag.storageContainerId} + HAVING ${count()} = ${unique.length} + )` +} diff --git a/packages/api/src/lib/container-selection/index.ts b/packages/api/src/lib/container-selection/index.ts new file mode 100644 index 0000000..4107540 --- /dev/null +++ b/packages/api/src/lib/container-selection/index.ts @@ -0,0 +1,100 @@ +/** + * Container selection (CONTEXT.md, ADR 0011): the Containers, with their Specimens, that a + * selection and its filters match. Every filter is defined once, here. The module selects + * only; callers enrich with `loadContainerReadViews` (ADR 0009). + */ +import { and, eq, getTableColumns, inArray, isNotNull, type SQL } from 'drizzle-orm' +import type { Database } from '../../db/client' +import { specimen, storageContainer, studySubject } from '../../db/schema' +import type { ContainerSubtype } from '../container-placement' +import { + containerFilterConditions, + containerTypeSql, + dateRangeConditions, + specimenFilterConditions, +} from './conditions' +import { micronixBarcodeScopes, subjectListScopes, type SelectionScope } from './scopes' +import type { ContainerSelection, ContainerSelectionResult, SelectedContainer, SelectedSpecimen } from './types' + +export * from './types' +export { containerHasAllTags } from './conditions' + +/** Select the Containers and Specimens a selection matches, with each Container's type. */ +export async function selectContainers( + database: Database, + selection: ContainerSelection, +): Promise { + if (selection.kind === 'micronixBarcodes') { + return runScopes(database, micronixBarcodeScopes(database, selection.barcodes), []) + } + + const filters = selection.filters ?? {} + const collectionDate = dateRangeConditions(specimen.collectionDate, filters.collectionDate) + let scopes: SelectionScope[] + switch (selection.kind) { + case 'study': { + const studySubjectIds = database + .select({ id: studySubject.id }) + .from(studySubject) + .where(eq(studySubject.studyId, selection.studyId)) + scopes = [{ specimens: and(inArray(specimen.studySubjectId, studySubjectIds), ...collectionDate) }] + break + } + case 'subjects': + scopes = await subjectListScopes(database, selection.subjects, { + collectionDate, + dateTolerance: selection.dateTolerance ?? 0, + }) + break + case 'database': { + const source = + selection.sourceType === 'subject' + ? isNotNull(specimen.studySubjectId) + : selection.sourceType === 'control' + ? isNotNull(specimen.controlBatchId) + : undefined + scopes = [{ specimens: and(source, ...collectionDate) }] + break + } + } + const specimenConditions = specimenFilterConditions(filters) + return runScopes( + database, + scopes.map((scope) => ({ ...scope, specimens: and(scope.specimens, ...specimenConditions) })), + containerFilterConditions(filters), + ) +} + +/** + * Run each scope (one per chunk of a listed selection) and collect its Specimens and the + * Containers that also pass the Container-level conditions. With any Container-level + * condition, only Specimens with a matching Container are kept. + */ +async function runScopes( + database: Database, + scopes: SelectionScope[], + containerConditions: SQL[], +): Promise { + const specimensById = new Map() + const containers: SelectedContainer[] = [] + const containerTypes = new Map() + for (const scope of scopes) { + for (const row of await database.select().from(specimen).where(scope.specimens)) specimensById.set(row.id, row) + const rows = await database + .select({ container: getTableColumns(storageContainer), containerType: containerTypeSql }) + .from(storageContainer) + .innerJoin(specimen, eq(storageContainer.specimenId, specimen.id)) + .where(and(scope.specimens, scope.containers, ...containerConditions)) + for (const row of rows) { + containers.push(row.container) + containerTypes.set(row.container.id, row.containerType) + } + } + let specimens = [...specimensById.values()] + if (containerConditions.length > 0) { + const stored = new Set(containers.map((container) => container.specimenId)) + specimens = specimens.filter((row) => stored.has(row.id)) + } + const byId = (a: { id: number }, b: { id: number }) => a.id - b.id + return { containers: containers.sort(byId), specimens: specimens.sort(byId), containerTypes } +} diff --git a/packages/api/src/lib/container-selection/scopes.ts b/packages/api/src/lib/container-selection/scopes.ts new file mode 100644 index 0000000..5e554c6 --- /dev/null +++ b/packages/api/src/lib/container-selection/scopes.ts @@ -0,0 +1,95 @@ +import { and, eq, inArray, or, type SQL } from 'drizzle-orm' +import type { SubjectDateFilter } from '@sampledb/contract' +import type { Database } from '../../db/client' +import { micronixTube, specimen, storageContainer, studySubject } from '../../db/schema' +import { shiftCalendarDate } from '../datetime' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' +import { dateRangeConditions } from './conditions' +import type { SubjectSelectionEntry } from './types' + +/** + * One bounded query's worth of a selection: the Specimens it covers and, for a barcode list, + * the Containers. A listed selection runs one scope per chunk, so no IN list grows with the + * input. + */ +export interface SelectionScope { + specimens: SQL | undefined + containers?: SQL +} + +/** Exactly the micronix tubes with these barcodes, and their Specimens, whatever their Source. */ +export function micronixBarcodeScopes(database: Database, barcodes: readonly string[]): SelectionScope[] { + const unique = [...new Set(barcodes.filter((barcode) => barcode !== ''))] + return chunkArray(unique, SQL_IN_CHUNK_SIZE).map((chunk) => { + const tubes = database.select({ id: micronixTube.id }).from(micronixTube).where(inArray(micronixTube.barcode, chunk)) + const tubeSpecimens = database + .select({ id: storageContainer.specimenId }) + .from(storageContainer) + .where(inArray(storageContainer.id, tubes)) + return { specimens: inArray(specimen.id, tubeSpecimens), containers: inArray(storageContainer.id, tubes) } + }) +} + +/** + * The listed Subjects, a chunk at a time. A Subject listed under a Study it isn't in matches + * nothing. A Subject with its own dates ignores the global collection-date range; one listed + * undated, on any entry, gets the global range. + */ +export async function subjectListScopes( + database: Database, + entries: readonly SubjectSelectionEntry[], + options: { collectionDate: SQL[]; dateTolerance: number }, +): Promise { + const datesBySubject = new Map() + for (const entry of entries) { + const listed = datesBySubject.get(entry.subjectId) ?? [] + let forStudy = listed.find((item) => item.studyId === entry.studyId) + if (!forStudy) { + forStudy = { studyId: entry.studyId, dates: [] } + listed.push(forStudy) + datesBySubject.set(entry.subjectId, listed) + } + const dateConditions = entry.dates ?? [] + if (dateConditions.length === 0) forStudy.dates = 'all' + else if (forStudy.dates !== 'all') forStudy.dates.push(...dateConditions) + } + + const scopes: SelectionScope[] = [] + for (const subjectIds of chunkArray([...datesBySubject.keys()], SQL_IN_CHUNK_SIZE)) { + const subjects = await database + .select({ id: studySubject.id, studyId: studySubject.studyId }) + .from(studySubject) + .where(inArray(studySubject.id, subjectIds)) + const undated: number[] = [] + const dated: SQL[] = [] + for (const subject of subjects) { + const listed = datesBySubject.get(subject.id)?.find((item) => item.studyId === subject.studyId) + if (!listed) continue + const dateConditions = + listed.dates === 'all' + ? [] + : listed.dates + .map((dateFilter) => subjectDateCondition(dateFilter, options.dateTolerance)) + .filter((condition) => condition !== undefined) + if (dateConditions.length === 0) undated.push(subject.id) + else dated.push(and(eq(specimen.studySubjectId, subject.id), or(...dateConditions))!) + } + if (undated.length > 0) { + dated.push(and(inArray(specimen.studySubjectId, undated), ...options.collectionDate)!) + } + if (dated.length > 0) scopes.push({ specimens: or(...dated) }) + } + return scopes +} + +/** Collection date condition for one per-Subject date: an exact date widened by the tolerance, or a range. */ +function subjectDateCondition(dateFilter: SubjectDateFilter, toleranceDays: number): SQL | undefined { + if ('exact' in dateFilter) { + const from = shiftCalendarDate(dateFilter.exact, -toleranceDays) + const to = shiftCalendarDate(dateFilter.exact, toleranceDays) + if (!from || !to) return eq(specimen.collectionDate, dateFilter.exact) + return and(...dateRangeConditions(specimen.collectionDate, { from, to })) + } + const range = dateRangeConditions(specimen.collectionDate, dateFilter) + return range.length > 0 ? and(...range) : undefined +} diff --git a/packages/api/src/lib/container-selection/types.ts b/packages/api/src/lib/container-selection/types.ts new file mode 100644 index 0000000..398be34 --- /dev/null +++ b/packages/api/src/lib/container-selection/types.ts @@ -0,0 +1,72 @@ +import type { SubjectDateFilter } from '@sampledb/contract' +import type { specimen, storageContainer } from '../../db/schema' +import type { ContainerSubtype } from '../container-placement' + +/** An inclusive date range. A bare `YYYY-MM-DD` upper bound includes that whole day. */ +export interface DateRange { + from?: string + to?: string +} + +/** Filters every selection takes, except a micronix barcode list (ADR 0011). */ +export interface ContainerSelectionFilters { + /** Specimen types; empty or omitted means every type. */ + specimenTypeIds?: readonly number[] + /** Collection date range. A listed Subject with its own dates ignores it. */ + collectionDate?: DateRange + /** When the Specimen was created. */ + specimenCreated?: DateRange + /** When the Container was created. A Container-level filter. */ + containerCreated?: DateRange + /** Container types (`ContainerSubtype` values); unknown values match nothing. A Container-level filter. */ + containerTypes?: readonly string[] + /** Containers carrying every one of these Tags. A Container-level filter. */ + tagIds?: readonly number[] + /** Containers stored in this Location or any Location under it. A Container-level filter. */ + locationId?: number +} + +/** One listed Subject of a subject-list selection. */ +export interface SubjectSelectionEntry { + studyId: number + subjectId: number + /** + * This Subject's collection dates; several are alternatives (one per visit). Omitted or + * empty: the global collection-date range applies. When a Subject is listed more than + * once, an undated entry wins. + */ + dates?: readonly SubjectDateFilter[] +} + +/** What to select from (ADR 0011). Every id is resolved by the caller; an unknown id matches nothing. */ +export type ContainerSelection = + | { kind: 'study'; studyId: number; filters?: ContainerSelectionFilters } + | { + kind: 'subjects' + subjects: readonly SubjectSelectionEntry[] + /** Days either side of each exact per-Subject date. Ranges are not widened. */ + dateTolerance?: number + filters?: ContainerSelectionFilters + } + | { kind: 'micronixBarcodes'; barcodes: readonly string[] } + | { + kind: 'database' + /** Specimens of Subjects or of Control batches; omitted means both. */ + sourceType?: 'subject' | 'control' + filters?: ContainerSelectionFilters + } + +export type SelectedContainer = typeof storageContainer.$inferSelect +export type SelectedSpecimen = typeof specimen.$inferSelect + +export interface ContainerSelectionResult { + /** Matching Containers, ordered by id. */ + containers: SelectedContainer[] + /** + * Matching Specimens, ordered by id. With no Container-level filter, every matching + * Specimen, with or without Containers; with one, only Specimens that have a matching Container. + */ + specimens: SelectedSpecimen[] + /** Each selected Container's type, by Container id. */ + containerTypes: Map +} From b5513702badaf501985007d9974dd1eab6f84906 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:50:10 -0700 Subject: [PATCH 72/85] feat(api): match every selected Tag in GET /containers. Breaking: tag_ids now lists only Containers carrying all the selected Tags, using containerHasAllTags from Container selection (ADR 0011). Pagination and the total stay in SQL. Co-Authored-By: Claude Opus 5.5 --- .../src/routes/__tests__/containers.test.ts | 71 ++++++++++++++++++- packages/api/src/routes/containers.ts | 30 ++------ 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/packages/api/src/routes/__tests__/containers.test.ts b/packages/api/src/routes/__tests__/containers.test.ts index ead182c..c56dd06 100644 --- a/packages/api/src/routes/__tests__/containers.test.ts +++ b/packages/api/src/routes/__tests__/containers.test.ts @@ -18,8 +18,10 @@ import { createTestStorageType, createTestCryovialBox, createTestCryovialTube, + createTestStorageContainer, + createTestTag, } from '../../__tests__/helpers/factories' -import { cryovialBox, cryovialTube, specimen, storageContainer, micronixPlate, micronixTube, box, sheet, paper, staticWell } from '../../db/schema' +import { cryovialBox, cryovialTube, specimen, storageContainer, micronixPlate, micronixTube, box, sheet, paper, staticWell, storageContainerTag } from '../../db/schema' import { eq } from 'drizzle-orm' describe('Containers API', () => { @@ -71,6 +73,73 @@ describe('Containers API', () => { expect(data.details).toBe('enrichment failed') vi.restoreAllMocks() }) + + describe('tag_ids filter', () => { + type ListResponse = { + containers: Array<{ id: number }> + pagination: { page: number; limit: number; total: number; totalPages: number } + } + + async function containerTagged(unitId: number, specimenId: number, tagIds: number[]) { + const container = await createTestStorageContainer(ctx.db, { unitId, specimenId }) + if (tagIds.length > 0) { + await ctx.db + .insert(storageContainerTag) + .values(tagIds.map((tagId) => ({ storageContainerId: container.id, tagId }))) + } + return container.id + } + + async function list(query: string): Promise { + const res = await ctx.request(`/api/containers?${query}`) + expect(res.status).toBe(200) + return (await res.json()) as ListResponse + } + + let red: number + let blue: number + let unused: number + let redAndBlue: number[] + let redOnly: number + + beforeEach(async () => { + red = (await createTestTag(ctx.db, { name: 'red' })).id + blue = (await createTestTag(ctx.db, { name: 'blue' })).id + unused = (await createTestTag(ctx.db, { name: 'unused' })).id + const unit = await createTestUnit(ctx.db, { symbol: 'uL', name: 'microliter', category: 'volume' }) + const specimenType = await createTestSpecimenType(ctx.db, { name: 'Blood' }) + const testSpecimen = await createTestSpecimen(ctx.db, specimenType.id) + redAndBlue = [ + await containerTagged(unit.id, testSpecimen.id, [red, blue]), + await containerTagged(unit.id, testSpecimen.id, [red, blue]), + await containerTagged(unit.id, testSpecimen.id, [red, blue]), + ] + redOnly = await containerTagged(unit.id, testSpecimen.id, [red]) + await containerTagged(unit.id, testSpecimen.id, [blue]) + await containerTagged(unit.id, testSpecimen.id, []) + }) + + it('lists Containers carrying the one selected Tag', async () => { + const data = await list(`tag_ids=${red}&limit=10`) + expect(data.containers.map((c) => c.id).sort((a, b) => a - b)).toEqual([...redAndBlue, redOnly]) + expect(data.pagination.total).toBe(4) + }) + + it('excludes Containers with only some of the selected Tags from the page and the total', async () => { + const first = await list(`tag_ids=${red}&tag_ids=${blue}&limit=2&page=1`) + const second = await list(`tag_ids=${red}&tag_ids=${blue}&limit=2&page=2`) + expect([...first.containers, ...second.containers].map((c) => c.id)).toEqual(redAndBlue) + expect(first.containers).toHaveLength(2) + expect(first.pagination).toEqual({ page: 1, limit: 2, total: 3, totalPages: 2 }) + expect(second.pagination).toEqual({ page: 2, limit: 2, total: 3, totalPages: 2 }) + }) + + it('returns an empty page when no Container carries every selected Tag', async () => { + const data = await list(`tag_ids=${red}&tag_ids=${unused}&limit=2`) + expect(data.containers).toEqual([]) + expect(data.pagination).toEqual({ page: 1, limit: 2, total: 0, totalPages: 0 }) + }) + }) }) describe('GET /api/containers/:id', () => { diff --git a/packages/api/src/routes/containers.ts b/packages/api/src/routes/containers.ts index f0bb191..71539db 100644 --- a/packages/api/src/routes/containers.ts +++ b/packages/api/src/routes/containers.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import type { Database } from '../db/client' import { storageContainer, micronixTube, cryovialTube, paper, staticWell, tag, storageContainerTag } from '../db/schema' -import { eq, and, sql, inArray } from 'drizzle-orm' +import { eq, and, sql } from 'drizzle-orm' import { z } from 'zod' import { validatePage, validateLimit } from '../lib/constants' import { createAuthMiddleware, createMemberMiddleware } from '../middleware/auth' @@ -12,6 +12,7 @@ import { enrichContainerForApi, enrichContainersForApi, toEnrichedContainerApi } import { loadContainerReadViews } from '../lib/container-read-view' import { mapEnrichedContainerToWire, mapEnrichedContainersToWire } from '../lib/container-wire-mapper' import { handleRouteError, RouteError, containersFetchFailedBody } from '../lib/error-handler' +import { containerHasAllTags } from '../lib/container-selection' /** * Create containers routes with database injection @@ -44,30 +45,9 @@ containers.get('/', authMiddleware, async (c) => { } } - // Filter by tags if provided - if (tagIds && tagIds.length > 0) { - const containerIdsWithTags = await database - .select({ containerId: storageContainerTag.storageContainerId }) - .from(storageContainerTag) - .where(inArray(storageContainerTag.tagId, tagIds)) - - const ids = [...new Set(containerIdsWithTags.map(r => r.containerId))] - if (ids.length > 0) { - conditions.push(inArray(storageContainer.id, ids)) - } else { - // No containers match the tags, return empty result - return c.json({ - containers: [], - pagination: { - page, - limit, - total: 0, - totalPages: 0, - }, - }) - } - } - + const tags = containerHasAllTags(tagIds ?? []) + if (tags) conditions.push(tags) + if (conditions.length > 0) { const whereClause = and(...conditions) as any query = query.where(whereClause) as any From de3dc5605937e10a96602600bb38e1d63392f64c Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:50:24 -0700 Subject: [PATCH 73/85] feat(export): select single-study GET export through Container selection. `GET /export/containers` now picks its Containers with `selectContainers` (ADR 0011), so the container type filter runs once, in the selection. A Study whose Containers are all of other types now returns 404 "No containers found for this study" instead of an empty file. `created_from` and `created_to` map to Container created. Co-Authored-By: Claude Opus 5.5 --- .../api/src/lib/export/container-export.ts | 61 ++++++++++------ .../api/src/routes/__tests__/export.test.ts | 71 +++++++++++++++++++ 2 files changed, 109 insertions(+), 23 deletions(-) diff --git a/packages/api/src/lib/export/container-export.ts b/packages/api/src/lib/export/container-export.ts index 05d62d3..6992cea 100644 --- a/packages/api/src/lib/export/container-export.ts +++ b/packages/api/src/lib/export/container-export.ts @@ -1,5 +1,8 @@ import type { ContainerExportData } from '@sampledb/contract' +import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' +import { study } from '../../db/schema' +import { selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '../container-selection' import { ExpectedNotFoundError, RouteError } from '../error-handler' import { enrichContainerData } from './enrich' import { filterContainerIdsByType } from './filter' @@ -83,6 +86,24 @@ export function buildPostExportEnvelope(params: { } } +/** + * Container selection filters for an export request (ADR 0011). Export's + * `created_from`/`created_to` mean Container created. + */ +function exportSelectionFilters(filters: Omit): ContainerSelectionFilters { + return { + specimenTypeIds: filters.specimen_type_ids, + collectionDate: { from: filters.date_from, to: filters.date_to }, + containerCreated: { from: filters.created_from, to: filters.created_to }, + containerTypes: filters.container_types, + tagIds: filters.tag_ids, + } +} + +async function findStudyByShortCode(database: Database, shortCode: string): Promise { + return database.select().from(study).where(eq(study.shortCode, shortCode)).get() +} + async function formatContainerExport( database: Database, data: ContainerExportData[], @@ -124,22 +145,23 @@ export async function runSingleStudyContainerExportGet( | { kind: 'json'; json: Awaited>; filename: string } > { const { filters, format, columns, csvOptions, userId, countOnly } = params - let result - try { - result = await buildContainerQuery(database, filters) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Not found' - throw new ExpectedNotFoundError(message) + const studyRecord = await findStudyByShortCode(database, filters.study) + if (!studyRecord) { + throw new ExpectedNotFoundError(`Study with short code '${filters.study}' not found`) } + const selectionFilters = exportSelectionFilters(filters) + const selection: ContainerSelection = filters.subject_ids?.length + ? { + kind: 'subjects', + subjects: filters.subject_ids.map((subjectId) => ({ studyId: studyRecord.id, subjectId })), + filters: selectionFilters, + } + : { kind: 'study', studyId: studyRecord.id, filters: selectionFilters } + const result = await selectContainers(database, selection) + if (countOnly) { - let filteredContainers = result.containers - if (filters.container_types?.length && filteredContainers.length > 0) { - const containerIds = filteredContainers.map((c) => c.id) - const matchingIds = await filterContainerIdsByType(database, containerIds, filters.container_types) - filteredContainers = filteredContainers.filter((c) => matchingIds.includes(c.id)) - } - return { kind: 'count', count: filteredContainers.length } + return { kind: 'count', count: result.containers.length } } if (result.containers.length === 0) { @@ -151,20 +173,13 @@ export async function runSingleStudyContainerExportGet( throw new RouteError(400, { error: 'Invalid format. Use csv, xlsx, or json.' }) } - const enrichedData = await enrichContainerData( - database, - result.containers, - result.specimens ?? [], - result.study, - filters.container_types, - undefined - ) + const enrichedData = await enrichContainerData(database, result.containers, result.specimens, studyRecord) const timestamp = formatExportTimestamp() - const filenameBase = `study_${result.study.shortCode}_export_${timestamp}` + const filenameBase = `study_${studyRecord.shortCode}_export_${timestamp}` if (format === 'json') { - const json = await formatAsJSON(database, enrichedData, filters, result.study, columns, userId) + const json = await formatAsJSON(database, enrichedData, filters, studyRecord, columns, userId) return { kind: 'json', json, filename: `${filenameBase}.json` } } diff --git a/packages/api/src/routes/__tests__/export.test.ts b/packages/api/src/routes/__tests__/export.test.ts index 31ca23e..9516c94 100644 --- a/packages/api/src/routes/__tests__/export.test.ts +++ b/packages/api/src/routes/__tests__/export.test.ts @@ -14,6 +14,13 @@ import { createTestSpecimenType, createTestSpecimen, createTestStorageContainer, + createTestUnit, + createTestStorageType, + createTestLocation, + createTestMicronixPlate, + createTestMicronixTube, + createTestCryovialBox, + createTestCryovialTube, } from '../../__tests__/helpers/factories' describe('Export API', () => { @@ -37,6 +44,32 @@ describe('Export API', () => { ctx.cleanup() }) + /** One Subject in a new Study with a micronix tube and, optionally, a cryovial tube. */ + async function seedTypedContainers(shortCode: string, options: { cryovial: boolean }) { + const study = await createTestStudy(ctx.db, { title: `${shortCode} Study`, shortCode }) + const subject = await createTestStudySubject(ctx.db, { studyId: study.id, name: 'Subj1' }) + const specimenType = await createTestSpecimenType(ctx.db, { name: 'Blood' }) + const unit = await createTestUnit(ctx.db, { symbol: 'uL', name: 'microliter', category: 'volume' }) + const storageType = await createTestStorageType(ctx.db, { name: 'Freezer -80' }) + const freezer = await createTestLocation(ctx.db, { + name: 'Freezer', + storageTypeId: String(storageType.id), + canContainCollections: true, + }) + const plate = await createTestMicronixPlate(ctx.db, { name: 'Plate', locationId: freezer.id }) + const spec = await createTestSpecimen(ctx.db, specimenType.id, { studySubjectId: subject.id }) + const micronix = await createTestStorageContainer(ctx.db, { specimenId: spec.id, unitId: unit.id as number }) + await createTestMicronixTube(ctx.db, { containerId: micronix.id, collectionId: plate.id, barcode: `${shortCode}-MX` }) + let cryovialId: number | undefined + if (options.cryovial) { + const box = await createTestCryovialBox(ctx.db, { name: 'Box', locationId: freezer.id }) + const cryovial = await createTestStorageContainer(ctx.db, { specimenId: spec.id, unitId: unit.id as number }) + await createTestCryovialTube(ctx.db, { containerId: cryovial.id, collectionId: box.id }) + cryovialId = cryovial.id + } + return { micronixId: micronix.id, cryovialId } + } + describe('GET /api/export/specimens.csv', () => { it('returns 200 with auth and CSV content type', async () => { const res = await ctx.request('/api/export/specimens.csv', { @@ -193,6 +226,44 @@ describe('Export API', () => { expect(rowsAfter.length).toBe(rowsBefore.length) }) + it('exports only Containers of the requested container_types', async () => { + const { cryovialId } = await seedTypedContainers('TYPES', { cryovial: true }) + + const res = await ctx.request('/api/export/containers?study=TYPES&format=json&container_types=cryovial_tube', { + method: 'GET', + }) + expect(res.status).toBe(200) + const data = (await res.json()) as { + export_metadata: { count: number } + containers: Array<{ container_id: number; container_type: string }> + } + expect(data.containers.map((row) => [row.container_id, row.container_type])).toEqual([ + [cryovialId, 'cryovial_tube'], + ]) + expect(data.export_metadata.count).toBe(1) + + const countRes = await ctx.request('/api/export/containers?study=TYPES&count_only=true&container_types=cryovial_tube', { + method: 'GET', + }) + expect(await countRes.json()).toEqual({ count: 1 }) + }) + + it('returns 404 when every Container in the study is of another container type', async () => { + await seedTypedContainers('MXONLY', { cryovial: false }) + + const res = await ctx.request('/api/export/containers?study=MXONLY&format=csv&container_types=cryovial_tube', { + method: 'GET', + }) + expect(res.status).toBe(404) + const data = (await res.json()) as { error?: string } + expect(data.error).toBe('No containers found for this study') + + const countRes = await ctx.request('/api/export/containers?study=MXONLY&count_only=true&container_types=cryovial_tube', { + method: 'GET', + }) + expect(await countRes.json()).toEqual({ count: 0 }) + }) + it('returns 400 for invalid format', async () => { const study = await createTestStudy(ctx.db, { title: 'Fmt Study', shortCode: 'FMT' }) const subject = await createTestStudySubject(ctx.db, { studyId: study.id, name: 'Subj1' }) From 4ce22ee8ee45c62a94f9d2e2400764ff4be04c5f Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:50:55 -0700 Subject: [PATCH 74/85] feat(web): label the dashboard's created filter Specimen created. The statistics created_from/created_to filter has always matched the Specimen's created date. The filter fields and their chips now say so, in the wording ADR 0011 uses. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/StatisticsFilter.tsx | 8 ++++---- packages/web/src/pages/Statistics.tsx | 4 ++-- .../web/src/pages/__tests__/Statistics.test.tsx | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/web/src/components/StatisticsFilter.tsx b/packages/web/src/components/StatisticsFilter.tsx index aab5dd0..202539c 100644 --- a/packages/web/src/components/StatisticsFilter.tsx +++ b/packages/web/src/components/StatisticsFilter.tsx @@ -480,10 +480,10 @@ export default function StatisticsFilter({ filters, onChange, onSubmit, isLoadin />
- {/* Created From */} + {/* Specimen Created From */}
- {/* Created To */} + {/* Specimen Created To */}
0) { const name = f.locationSelections?.[0]?.name ?? `Location ${f.locationId ?? ''}` chips.push({ key: 'locationId', label: `Location: ${name}` }) diff --git a/packages/web/src/pages/__tests__/Statistics.test.tsx b/packages/web/src/pages/__tests__/Statistics.test.tsx index c2c8970..d9fd233 100644 --- a/packages/web/src/pages/__tests__/Statistics.test.tsx +++ b/packages/web/src/pages/__tests__/Statistics.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import userEvent from '@testing-library/user-event' import { render, screen, waitFor } from '../../__tests__/helpers/render' import Statistics from '../Statistics' import { statisticsApi } from '../../lib/api/statistics' @@ -71,6 +72,22 @@ describe('Statistics', () => { }) }) + it('filters and labels the date range as Specimen created', async () => { + const user = userEvent.setup() + await render() + await user.click(await screen.findByRole('button', { name: /filters/i })) + + await user.type(screen.getByLabelText('Specimen Created From'), '2024-01-01') + await user.type(screen.getByLabelText('Specimen Created To'), '2024-12-31') + await user.click(screen.getByRole('button', { name: /apply filters/i })) + + expect(await screen.findByText('Specimen created from: 2024-01-01')).toBeInTheDocument() + expect(screen.getByText('Specimen created to: 2024-12-31')).toBeInTheDocument() + expect(statisticsApi.get).toHaveBeenLastCalledWith( + expect.objectContaining({ created_from: '2024-01-01', created_to: '2024-12-31' }), + ) + }) + // We do not defend against malformed API responses: the server is under our control // and must return the full StatisticsData shape; malformed data indicates a server bug and we fail loudly. }) From b53074dcf1c884c34123ed132ef24c8ef5801885 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:51:35 -0700 Subject: [PATCH 75/85] fix(export): return the rows from JSON POST container exports. formatContainerExport returned the JSON rows as `json`, but buildPostExportEnvelope reads `jsonData`, so every POST container export in JSON format sent an empty `data` array. New executor tests for multi-study and barcode export pin the rows, the per-Study summary and the barcode summary before those paths move to Container selection. Co-Authored-By: Claude Opus 5.5 --- .../export/__tests__/container-export.test.ts | 300 ++++++++++++++++++ .../api/src/lib/export/container-export.ts | 4 +- 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/lib/export/__tests__/container-export.test.ts diff --git a/packages/api/src/lib/export/__tests__/container-export.test.ts b/packages/api/src/lib/export/__tests__/container-export.test.ts new file mode 100644 index 0000000..5c2a169 --- /dev/null +++ b/packages/api/src/lib/export/__tests__/container-export.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { eq } from 'drizzle-orm' +import type { Database as SQLiteDatabase } from 'bun:sqlite' +import type { ContainerExportData } from '@sampledb/contract' +import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' +import { + createTestControlBatch, + createTestControlDefinition, + createTestCryovialBox, + createTestCryovialTube, + createTestLocation, + createTestMicronixPlate, + createTestMicronixTube, + createTestSpecimen, + createTestSpecimenType, + createTestStorageContainer, + createTestStorageType, + createTestStudy, + createTestStudySubject, + createTestTag, + createTestUnit, +} from '../../../__tests__/helpers/factories' +import type { Database } from '../../../db/client' +import { specimen as specimenTable, storageContainerTag } from '../../../db/schema' +import { runBarcodeContainerExportPost, runMultiStudyContainerExportPost } from '../container-export' +import type { MultiStudyExportSummary } from '../types' + +const columns = ['container_id', 'container_type', 'barcode', 'subject_name', 'study_code', 'control_batch_name'] + +describe('Container export executors', () => { + let db: Database + let sqlite: SQLiteDatabase + let unitId: number + let specimenTypeId: number + let plateId: number + let cryovialBoxId: number + let barcodeCount = 0 + + beforeEach(async () => { + const setup = await setupTestDatabase() + db = setup.db + sqlite = setup.sqlite + unitId = (await createTestUnit(db, { symbol: 'uL', name: 'microliter', category: 'volume' })).id as number + specimenTypeId = (await createTestSpecimenType(db, { name: 'Blood' })).id + const storageTypeId = String((await createTestStorageType(db, { name: 'Freezer -80' })).id) + const freezer = await createTestLocation(db, { name: 'Freezer', storageTypeId, canContainCollections: true }) + plateId = (await createTestMicronixPlate(db, { name: 'Plate', locationId: freezer.id })).id + cryovialBoxId = (await createTestCryovialBox(db, { name: 'Box', locationId: freezer.id })).id + }) + + afterEach(() => cleanupTestDatabase(sqlite)) + + async function seedSpecimen( + source: { studySubjectId: number } | { controlBatchId: number }, + collectionDate?: string, + ) { + const row = await createTestSpecimen(db, specimenTypeId, source) + await db + .update(specimenTable) + .set({ collectionDate: collectionDate ?? null }) + .where(eq(specimenTable.id, row.id)) + return row + } + + async function seedTube(specimenId: number, barcode?: string) { + const container = await createTestStorageContainer(db, { specimenId, unitId }) + barcodeCount += 1 + await createTestMicronixTube(db, { + containerId: container.id, + collectionId: plateId, + barcode: barcode ?? `MX${barcodeCount}`, + }) + return container + } + + async function seedCryovial(specimenId: number) { + const container = await createTestStorageContainer(db, { specimenId, unitId }) + await createTestCryovialTube(db, { containerId: container.id, collectionId: cryovialBoxId }) + return container + } + + const containerIds = (data: unknown) => + (data as ContainerExportData[]).map((row) => row.container_id).sort((a, b) => a - b) + + describe('runMultiStudyContainerExportPost', () => { + /** Two Studies sharing a Subject name, with visits, a cryovial and an unknown Subject. */ + async function seedTwoStudies() { + const alpha = await createTestStudy(db, { title: 'Alpha', shortCode: 'ALPHA', leadPerson: 'Ada' }) + const beta = await createTestStudy(db, { title: 'Beta', shortCode: 'BETA', leadPerson: 'Bo' }) + const alphaS01 = await createTestStudySubject(db, { studyId: alpha.id, name: 'S01' }) + const alphaS02 = await createTestStudySubject(db, { studyId: alpha.id, name: 'S02' }) + const betaS01 = await createTestStudySubject(db, { studyId: beta.id, name: 'S01' }) + const alphaJan = await seedTube((await seedSpecimen({ studySubjectId: alphaS01.id }, '2024-01-01')).id) + await seedTube((await seedSpecimen({ studySubjectId: alphaS01.id }, '2024-03-01')).id) + const alphaS02Cryovial = await seedCryovial((await seedSpecimen({ studySubjectId: alphaS02.id }, '2024-01-01')).id) + const betaSpecimen = await seedSpecimen({ studySubjectId: betaS01.id }, '2024-06-01') + const betaTube = await seedTube(betaSpecimen.id) + const betaCryovial = await seedCryovial(betaSpecimen.id) + return { alphaJan, alphaS02Cryovial, betaTube, betaCryovial } + } + + const entries = [ + { study_short_code: 'ALPHA', subject_name: 'S01', collection_date: '2024-01-01' }, + { study_short_code: 'ALPHA', subject_name: 'S02' }, + { study_short_code: 'ALPHA', subject_name: 'NOBODY' }, + { study_short_code: 'BETA', subject_name: 'S01' }, + ] + + it('exports every listed Subject across Studies and summarises each Study', async () => { + const { alphaJan, alphaS02Cryovial, betaTube, betaCryovial } = await seedTwoStudies() + + const result = await runMultiStudyContainerExportPost(db, { entries, filters: {}, format: 'json', columns }) + + if (!('data' in result)) throw new Error('expected an export envelope') + expect(containerIds(result.data)).toEqual([alphaJan.id, alphaS02Cryovial.id, betaTube.id, betaCryovial.id]) + const studyCodeByContainer = new Map( + (result.data as ContainerExportData[]).map((row) => [row.container_id, row.study_code]), + ) + expect(studyCodeByContainer.get(alphaJan.id)).toBe('ALPHA') + expect(studyCodeByContainer.get(betaTube.id)).toBe('BETA') + expect(result.summary).toEqual({ + total_containers: 4, + studies: [ + { + study_code: 'ALPHA', + study_title: 'Alpha', + study_lead_person: 'Ada', + containers: 2, + subjects_with_results: [ + { name: 'S01', count: 1 }, + { name: 'S02', count: 1 }, + ], + subjects_no_results: [], + subjects_not_found: ['NOBODY'], + }, + { + study_code: 'BETA', + study_title: 'Beta', + study_lead_person: 'Bo', + containers: 2, + subjects_with_results: [{ name: 'S01', count: 2 }], + subjects_no_results: [], + subjects_not_found: [], + }, + ], + invalid_study_codes: [], + } satisfies MultiStudyExportSummary) + }) + + it('applies the container type filter once and counts Subjects left without Containers', async () => { + const { alphaJan, betaTube } = await seedTwoStudies() + + const result = await runMultiStudyContainerExportPost(db, { + entries, + filters: { container_types: ['micronix_tube'] }, + format: 'json', + columns, + }) + + if (!('data' in result)) throw new Error('expected an export envelope') + expect(containerIds(result.data)).toEqual([alphaJan.id, betaTube.id]) + const summary = result.summary as MultiStudyExportSummary + expect(summary.total_containers).toBe(2) + expect(summary.studies[0]).toMatchObject({ + containers: 1, + subjects_with_results: [{ name: 'S01', count: 1 }], + subjects_no_results: ['S02'], + subjects_not_found: ['NOBODY'], + }) + expect(summary.studies[1]).toMatchObject({ containers: 1, subjects_with_results: [{ name: 'S01', count: 1 }] }) + }) + + it('keeps only Containers carrying every selected Tag', async () => { + const { alphaJan, betaTube } = await seedTwoStudies() + const first = await createTestTag(db, { name: 'first' }) + const second = await createTestTag(db, { name: 'second' }) + await db.insert(storageContainerTag).values([ + { storageContainerId: alphaJan.id, tagId: first.id }, + { storageContainerId: alphaJan.id, tagId: second.id }, + { storageContainerId: betaTube.id, tagId: first.id }, + ]) + + const result = await runMultiStudyContainerExportPost(db, { + entries, + filters: { tag_ids: [first.id, second.id] }, + format: 'json', + columns, + }) + + if (!('data' in result)) throw new Error('expected an export envelope') + expect(containerIds(result.data)).toEqual([alphaJan.id]) + }) + + it('counts with the same summary as the export', async () => { + await seedTwoStudies() + + const exported = await runMultiStudyContainerExportPost(db, { entries, filters: {}, format: 'json', columns }) + const counted = await runMultiStudyContainerExportPost(db, { + entries, + filters: {}, + format: 'json', + countOnly: true, + }) + + expect(counted).toEqual({ count: 4, summary: exported.summary as MultiStudyExportSummary }) + }) + + it('reports a Study whose Subjects are all unknown', async () => { + await seedTwoStudies() + + const result = await runMultiStudyContainerExportPost(db, { + entries: [{ study_short_code: 'BETA', subject_name: 'NOBODY' }], + filters: {}, + format: 'json', + countOnly: true, + }) + + expect(result).toEqual({ + count: 0, + summary: { + total_containers: 0, + studies: [ + { + study_code: 'BETA', + study_title: 'Beta', + study_lead_person: 'Bo', + containers: 0, + subjects_with_results: [], + subjects_no_results: [], + subjects_not_found: ['NOBODY'], + }, + ], + invalid_study_codes: [], + }, + }) + }) + + it('exports nothing when a study code is invalid', async () => { + await seedTwoStudies() + + const result = await runMultiStudyContainerExportPost(db, { + entries: [...entries, { study_short_code: 'NOPE', subject_name: 'S01' }], + filters: {}, + format: 'json', + countOnly: true, + }) + + expect(result).toEqual({ + count: 0, + summary: { + total_containers: 0, + studies: [], + invalid_study_codes: ['NOPE'], + errors: ['Invalid study codes: NOPE'], + }, + }) + }) + }) + + describe('runBarcodeContainerExportPost', () => { + it('exports exactly the listed tubes, whatever their Source, and reports unknown barcodes', async () => { + const alpha = await createTestStudy(db, { title: 'Alpha', shortCode: 'ALPHA' }) + const beta = await createTestStudy(db, { title: 'Beta', shortCode: 'BETA' }) + const alphaSubject = await createTestStudySubject(db, { studyId: alpha.id, name: 'S01' }) + const betaSubject = await createTestStudySubject(db, { studyId: beta.id, name: 'S01' }) + const batch = await createTestControlBatch(db, (await createTestControlDefinition(db)).id, { name: 'Batch 1' }) + const alphaSpecimen = await seedSpecimen({ studySubjectId: alphaSubject.id }) + const alphaTube = await seedTube(alphaSpecimen.id, 'A1') + await seedTube(alphaSpecimen.id, 'A2') + const betaTube = await seedTube((await seedSpecimen({ studySubjectId: betaSubject.id })).id, 'B1') + const controlTube = await seedTube((await seedSpecimen({ controlBatchId: batch.id })).id, 'C1') + + const result = await runBarcodeContainerExportPost(db, { + barcodes: ['A1', 'B1', 'C1', 'NOPE', 'A1'], + format: 'json', + columns, + }) + + expect(result.summary).toEqual({ + total_containers: 3, + barcodes_found: ['A1', 'B1', 'C1'], + barcodes_not_found: ['NOPE'], + }) + const rows = result.data as ContainerExportData[] + expect(containerIds(rows)).toEqual([alphaTube.id, betaTube.id, controlTube.id]) + const byId = new Map(rows.map((row) => [row.container_id, row])) + expect(byId.get(alphaTube.id)?.study_code).toBe('ALPHA') + expect(byId.get(betaTube.id)?.study_code).toBe('BETA') + expect(byId.get(controlTube.id)).toMatchObject({ control_batch_name: 'Batch 1' }) + expect(byId.get(controlTube.id)?.study_code).toBeUndefined() + }) + + it('returns an empty export when no barcode matches', async () => { + const result = await runBarcodeContainerExportPost(db, { barcodes: ['NOPE'], format: 'json', columns }) + + expect(result.summary).toEqual({ total_containers: 0, barcodes_found: [], barcodes_not_found: ['NOPE'] }) + expect(result.data).toEqual([]) + }) + }) +}) diff --git a/packages/api/src/lib/export/container-export.ts b/packages/api/src/lib/export/container-export.ts index 05d62d3..ede5bc5 100644 --- a/packages/api/src/lib/export/container-export.ts +++ b/packages/api/src/lib/export/container-export.ts @@ -92,10 +92,10 @@ async function formatContainerExport( columns: string[] | undefined, csvOptions: CSVExportOptions | undefined, userId: number | null | undefined -): Promise<{ csv?: string; xlsx?: Buffer; json?: ContainerExportData[] }> { +): Promise<{ csv?: string; xlsx?: Buffer; jsonData?: ContainerExportData[] }> { if (format === 'json') { const json = await formatAsJSON(database, data, filters, study, columns, userId) - return { json: json.containers } + return { jsonData: json.containers } } if (format === 'csv') { From 1bf007648de4f3af00c5da4b53094c45900ff6ca Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:53:04 -0700 Subject: [PATCH 76/85] feat(export): select single-study POST export through Container selection. `POST /export/containers` now builds a subject-list selection from the resolved subject names and their per-subject dates, and selects with `selectContainers` (ADR 0011). Count-only and full export share the one selection, so neither calls `filterContainerIdsByType` any more. The summary is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../api/src/lib/export/container-export.ts | 83 ++++++++----------- .../api/src/routes/__tests__/export.test.ts | 78 ++++++++++++++++- 2 files changed, 110 insertions(+), 51 deletions(-) diff --git a/packages/api/src/lib/export/container-export.ts b/packages/api/src/lib/export/container-export.ts index 6992cea..0cdd08f 100644 --- a/packages/api/src/lib/export/container-export.ts +++ b/packages/api/src/lib/export/container-export.ts @@ -1,15 +1,19 @@ -import type { ContainerExportData } from '@sampledb/contract' +import type { ContainerExportData, SubjectDateFilter } from '@sampledb/contract' import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' import { study } from '../../db/schema' -import { selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '../container-selection' +import { + selectContainers, + type ContainerSelection, + type ContainerSelectionFilters, + type SubjectSelectionEntry, +} from '../container-selection' import { ExpectedNotFoundError, RouteError } from '../error-handler' import { enrichContainerData } from './enrich' -import { filterContainerIdsByType } from './filter' import { formatAsCSV, formatAsExcel, formatAsJSON } from './format' -import { buildContainerQuery, buildContainerQueryByMicronixBarcodes, buildMultiStudyContainerQuery, resolveMicronixBarcodesToContainers } from './query' +import { buildContainerQueryByMicronixBarcodes, buildMultiStudyContainerQuery, resolveMicronixBarcodesToContainers } from './query' import { buildExportSummary, validateStudyCodes } from './validate' -import { resolveSubjectNamesByStudy, resolveStudyByShortCode } from '../identifier-resolution' +import { resolveSubjectNamesByStudy } from '../identifier-resolution' import type { CSVExportOptions, ExportFilters, @@ -203,7 +207,8 @@ export async function runSingleStudyContainerExportPost( csvOptions?: CSVExportOptions userId?: number | null countOnly?: boolean - subjectDates?: ExportFilters['subject_dates'] + /** Per-Subject collection dates, keyed by subject name. */ + subjectDates?: Record dateTolerance?: number } ): Promise { @@ -220,19 +225,18 @@ export async function runSingleStudyContainerExportPost( dateTolerance = 0, } = params - const studyId = await resolveStudyByShortCode(database, studyCode) - if (!studyId) { + const studyRecord = await findStudyByShortCode(database, studyCode) + if (!studyRecord) { throw new ExpectedNotFoundError('Study not found') } - const subjectNameToId = await resolveSubjectNamesByStudy(database, subjectNames, studyId) - const subjectIds = Array.from(subjectNameToId.values()) + const subjectNameToId = await resolveSubjectNamesByStudy(database, subjectNames, studyRecord.id) const subjectIdToName = new Map() for (const [name, id] of subjectNameToId.entries()) { subjectIdToName.set(id, name) } - if (subjectIds.length === 0) { + if (subjectNameToId.size === 0) { const summary: ExportSummary = { total_containers: 0, subjects_with_results: [], @@ -255,55 +259,34 @@ export async function runSingleStudyContainerExportPost( }) } - const queryFilters: ExportFilters = { - ...filters, - study: studyCode, - subject_ids: subjectIds, - date_tolerance: dateTolerance, - } - - if (subjectDates) { - queryFilters.subject_dates = {} - for (const [subjectName, dateFilter] of Object.entries(subjectDates)) { - const subjectId = subjectNameToId.get(subjectName) - if (subjectId) { - queryFilters.subject_dates![subjectId] = dateFilter as NonNullable[number] - } + const subjects: SubjectSelectionEntry[] = [...subjectNameToId].map(([name, subjectId]) => { + const dates = subjectDates?.[name] + return { + studyId: studyRecord.id, + subjectId, + dates: dates === undefined ? undefined : Array.isArray(dates) ? dates : [dates], } - } - - const result = await buildContainerQuery(database, queryFilters).catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'Not found' - throw new ExpectedNotFoundError(message) + }) + const result = await selectContainers(database, { + kind: 'subjects', + subjects, + dateTolerance, + filters: exportSelectionFilters(filters), }) if (countOnly) { - let filteredContainers = result.containers - if (queryFilters.container_types?.length) { - const containerIds = result.containers.map((c) => c.id) - const matchingIds = await filterContainerIdsByType(database, containerIds, queryFilters.container_types) - filteredContainers = result.containers.filter((c) => matchingIds.includes(c.id)) - } - const summary: ExportSummary = { - total_containers: filteredContainers.length, + total_containers: result.containers.length, subjects_with_results: [], subjects_no_results: [], subjects_not_found: subjectNames.filter((name) => !subjectNameToId.has(name)), errors: [], } - return { count: filteredContainers.length, summary } + return { count: result.containers.length, summary } } - const enrichedData = await enrichContainerData( - database, - result.containers, - result.specimens ?? [], - result.study, - queryFilters.container_types, - undefined - ) + const enrichedData = await enrichContainerData(database, result.containers, result.specimens, studyRecord) const summary = await buildExportSummary( enrichedData, @@ -316,8 +299,8 @@ export async function runSingleStudyContainerExportPost( database, enrichedData, format, - queryFilters, - result.study, + { ...filters, study: studyCode, date_tolerance: dateTolerance }, + studyRecord, columns, csvOptions, userId @@ -325,7 +308,7 @@ export async function runSingleStudyContainerExportPost( return buildPostExportEnvelope({ format, - filenameBase: `study_${result.study.shortCode}_export_${formatExportTimestamp()}`, + filenameBase: `study_${studyRecord.shortCode}_export_${formatExportTimestamp()}`, summary, ...formatted, }) diff --git a/packages/api/src/routes/__tests__/export.test.ts b/packages/api/src/routes/__tests__/export.test.ts index 9516c94..d94ed31 100644 --- a/packages/api/src/routes/__tests__/export.test.ts +++ b/packages/api/src/routes/__tests__/export.test.ts @@ -6,7 +6,7 @@ import { type AuthenticatedRouteTestContext, } from '../../__tests__/helpers/authenticated-route-test' import { createExportRoutes } from '../export' -import { errorLogs } from '../../db/schema' +import { errorLogs, specimen as specimenTable } from '../../db/schema' import * as specimensCsv from '../../lib/export/specimens-csv' import { createTestStudy, @@ -314,6 +314,82 @@ describe('Export API', () => { }) }) + describe('POST /api/export/containers filters', () => { + type PostSummary = { + total_containers: number + subjects_with_results: Array<{ name: string; count: number }> + subjects_no_results: string[] + subjects_not_found: string[] + } + + /** POST a CSV export; return its summary and each row's container_id and container_type. */ + async function postCsvExport(body: Record) { + const res = await ctx.request('/api/export/containers', { + method: 'POST', + json: { ...body, format: 'csv', csv_bom: false, csv_line_ending: 'LF' }, + }) + expect(res.status).toBe(200) + const data = (await res.json()) as { summary: PostSummary; data: string } + const [header, ...lines] = Buffer.from(data.data, 'base64').toString('utf8').trim().split('\n') + expect(header.startsWith('container_id,container_type,')).toBe(true) + const rows = lines.map((line) => { + const [id, type] = line.split(',') + return [Number(id), type] as const + }) + return { summary: data.summary, rows } + } + + it('exports and counts only Containers of the requested container_types', async () => { + const { cryovialId } = await seedTypedContainers('PTYPES', { cryovial: true }) + const body = { study: 'PTYPES', subject_names: ['Subj1', 'Ghost'], container_types: ['cryovial_tube'] } + + const exported = await postCsvExport(body) + expect(exported.rows).toEqual([[cryovialId, 'cryovial_tube']]) + expect(exported.summary).toEqual({ + total_containers: 1, + subjects_with_results: [{ name: 'Subj1', count: 1 }], + subjects_no_results: [], + subjects_not_found: ['Ghost'], + }) + + const countRes = await ctx.request('/api/export/containers', { + method: 'POST', + json: { ...body, count_only: true }, + }) + const counted = (await countRes.json()) as { count: number; summary: PostSummary } + expect(counted.count).toBe(1) + expect(counted.summary.total_containers).toBe(1) + expect(counted.summary.subjects_not_found).toEqual(['Ghost']) + }) + + it('applies per-subject dates with tolerance, and the global range to undated subjects', async () => { + const study = await createTestStudy(ctx.db, { title: 'Dates Study', shortCode: 'PDATES' }) + const specimenType = await createTestSpecimenType(ctx.db, { name: 'Blood' }) + const unit = await createTestUnit(ctx.db, { symbol: 'uL', name: 'microliter', category: 'volume' }) + const containerOn = async (subjectId: number, collectionDate: string) => { + const spec = await createTestSpecimen(ctx.db, specimenType.id, { studySubjectId: subjectId }) + await ctx.db.update(specimenTable).set({ collectionDate }).where(eq(specimenTable.id, spec.id)) + return (await createTestStorageContainer(ctx.db, { specimenId: spec.id, unitId: unit.id as number })).id + } + const dated = await createTestStudySubject(ctx.db, { studyId: study.id, name: 'Dated' }) + const undated = await createTestStudySubject(ctx.db, { studyId: study.id, name: 'Undated' }) + const datedJan = await containerOn(dated.id, '2024-01-01') + await containerOn(dated.id, '2024-03-01') + await containerOn(undated.id, '2024-01-15') + const undatedFeb = await containerOn(undated.id, '2024-02-15') + + const exported = await postCsvExport({ + study: 'PDATES', + subject_names: ['Dated', 'Undated'], + subject_dates: { Dated: { exact: '2024-01-02' } }, + date_tolerance: 1, + date_from: '2024-02-01', + }) + expect(exported.rows.map(([id]) => id).sort((a, b) => a - b)).toEqual([datedJan, undatedFeb]) + expect(exported.summary.total_containers).toBe(2) + }) + }) + describe('POST /api/export/containers/validate-studies', () => { it('returns valid and invalid study codes', async () => { await createTestStudy(ctx.db, { title: 'Valid', shortCode: 'VALID' }) From 16483fc7d998d940edb161aa8546b57a89eea1e2 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:53:06 -0700 Subject: [PATCH 77/85] refactor(statistics): select dashboard statistics through Container selection. getDashboardStatistics now asks selectContainers for its Containers and Specimens. created_from/created_to map to Specimen created and specimen_type_id to a one-item list. The inline all-Tags intersection, the unreachable location- and tag-only branch, the statistics location helpers and buildDateFilter are gone. The location filter no longer needs the raw SQLite handle, so the statistics routes drop it. The wire shape is unchanged. A filter that matches no Specimen returns the all-zero statistics, without the storage summary, as an unknown Study or Tag already did. Co-Authored-By: Claude Opus 5.5 --- packages/api/src/index.ts | 2 +- .../__tests__/dashboard-stats.test.ts | 69 +++- .../__tests__/location-filter.test.ts | 122 ------ .../api/src/lib/statistics/dashboard-stats.ts | 352 ++++-------------- packages/api/src/lib/statistics/helpers.ts | 15 - .../api/src/lib/statistics/location-filter.ts | 92 ----- .../src/routes/__tests__/statistics.test.ts | 4 +- packages/api/src/routes/statistics.ts | 11 +- 8 files changed, 137 insertions(+), 530 deletions(-) delete mode 100644 packages/api/src/lib/statistics/__tests__/location-filter.test.ts delete mode 100644 packages/api/src/lib/statistics/helpers.ts delete mode 100644 packages/api/src/lib/statistics/location-filter.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index c087a88..8545c0c 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -188,7 +188,7 @@ app.route('/api/strains', createStrainsRoutes(db)) app.route('/api/cell-lines', createCellLinesRoutes(db)) app.route('/api/plasmids', createPlasmidsRoutes(db)) app.route('/api/standards', createStandardsRoutes(db)) -app.route('/api/statistics', createStatisticsRoutes(db, sqlite)) +app.route('/api/statistics', createStatisticsRoutes(db)) app.route('/api/tags', createTagsRoutes(db)) app.route('/api/settings', createSettingsRoutes(db)) app.route('/api/units', createUnitsRoutes(db)) diff --git a/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts b/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts index bdc6e53..9cadc97 100644 --- a/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts +++ b/packages/api/src/lib/statistics/__tests__/dashboard-stats.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import type { Database } from '../../../db/client' -import type { Database as SQLiteDatabase } from 'bun:sqlite' import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' import { createTestLocation, @@ -10,9 +9,13 @@ import { createTestSpecimen, createTestSpecimenType, createTestStudy, + createTestStudySubject, + createTestTag, + createTestUnit, } from '../../../__tests__/helpers/factories' import { utcNow } from '../../datetime' -import { micronixTube } from '../../../db/schema' +import { eq } from 'drizzle-orm' +import { micronixTube, specimen, storageContainerTag } from '../../../db/schema' import { getDashboardStatistics } from '../dashboard-stats' describe('dashboard-stats', () => { @@ -32,7 +35,7 @@ describe('dashboard-stats', () => { }) it('returns expected shape on empty database', async () => { - const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, {}) + const data = await getDashboardStatistics(testDb, {}) expect(data.specimens).toMatchObject({ total: 0, @@ -54,7 +57,7 @@ describe('dashboard-stats', () => { }) it('returns empty results for unknown study short code', async () => { - const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, { + const data = await getDashboardStatistics(testDb, { study: 'NONEXISTENT', }) @@ -68,8 +71,8 @@ describe('dashboard-stats', () => { const type = await createTestSpecimenType(testDb, { name: 'Blood' }) await createTestSpecimen(testDb, type.id) - const all = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, {}) - const unknown = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, { study: 'NONEXISTENT' }) + const all = await getDashboardStatistics(testDb, {}) + const unknown = await getDashboardStatistics(testDb, { study: 'NONEXISTENT' }) expect(all.specimens.total).toBe(1) expect(unknown.specimens.total).toBe(0) @@ -80,11 +83,57 @@ describe('dashboard-stats', () => { await createTestSpecimen(testDb, type.id) const today = utcNow().slice(0, 10) - const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, { created_to: today }) + const data = await getDashboardStatistics(testDb, { created_to: today }) expect(data.specimens.total).toBe(1) }) + it('filters created_from and created_to on when the Specimen was created', async () => { + const type = await createTestSpecimenType(testDb, { name: 'Blood' }) + const older = await createTestSpecimen(testDb, type.id) + await createTestStorageContainer(testDb, { specimenId: older.id }) + await testDb.update(specimen).set({ created: '2020-06-01T00:00:00.000Z' }).where(eq(specimen.id, older.id)) + await createTestSpecimen(testDb, type.id) + + const data = await getDashboardStatistics(testDb, { created_from: '2020-01-01', created_to: '2020-12-31' }) + + expect(data.specimens.total).toBe(1) + expect(data.containers.total).toBe(1) + }) + + it('counts only Specimens with a Container carrying every selected Tag', async () => { + const type = await createTestSpecimenType(testDb, { name: 'Blood' }) + const red = await createTestTag(testDb, { name: 'red' }) + const blue = await createTestTag(testDb, { name: 'blue' }) + const unitId = (await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' })).id + const both = await createTestStorageContainer(testDb, { specimenId: (await createTestSpecimen(testDb, type.id)).id, unitId }) + const redOnly = await createTestStorageContainer(testDb, { specimenId: (await createTestSpecimen(testDb, type.id)).id, unitId }) + await testDb.insert(storageContainerTag).values([ + { storageContainerId: both.id, tagId: red.id }, + { storageContainerId: both.id, tagId: blue.id }, + { storageContainerId: redOnly.id, tagId: red.id }, + ]) + + const data = await getDashboardStatistics(testDb, { tag_ids: [red.id, blue.id] }) + + expect(data.specimens.total).toBe(1) + expect(data.containers.total).toBe(1) + expect(data.containers.averagePerSpecimen).toBe(1) + }) + + it('matches nothing for control Specimens within a Study', async () => { + const study = await createTestStudy(testDb, { title: 'Study', shortCode: 'ST' }) + const subject = await createTestStudySubject(testDb, { studyId: study.id, name: 'S1' }) + const type = await createTestSpecimenType(testDb, { name: 'Blood' }) + await createTestSpecimen(testDb, type.id, { studySubjectId: subject.id }) + + const subjects = await getDashboardStatistics(testDb, { study: 'ST', source_type: 'subject' }) + const controls = await getDashboardStatistics(testDb, { study: 'ST', source_type: 'control' }) + + expect(subjects.specimens.total).toBe(1) + expect(controls.specimens.total).toBe(0) + }) + it('includes containers at a filtered location', async () => { const storageType = await createTestStorageType(testDb, { name: 'Stats Storage' }) const loc = await createTestLocation(testDb, { @@ -100,7 +149,7 @@ describe('dashboard-stats', () => { position: 'B01', }) - const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, { + const data = await getDashboardStatistics(testDb, { location_id: String(loc.id), }) @@ -111,7 +160,7 @@ describe('dashboard-stats', () => { it('returns byStatus for containers with remaining quantity', async () => { const container = await createTestStorageContainer(testDb) - const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, {}) + const data = await getDashboardStatistics(testDb, {}) expect(data.containers.byStatus['In Use']).toBe(1) expect(data.containers.total).toBe(1) @@ -134,7 +183,7 @@ describe('dashboard-stats', () => { } await createTestSpecimen(testDb, type.id) // in another study - const data = await getDashboardStatistics(testDb, sqlite as SQLiteDatabase, { study: 'BIG' }) + const data = await getDashboardStatistics(testDb, { study: 'BIG' }) expect(data.specimens.total).toBe(3) expect(data.specimens.byStudy).toEqual({ BIG: 3 }) diff --git a/packages/api/src/lib/statistics/__tests__/location-filter.test.ts b/packages/api/src/lib/statistics/__tests__/location-filter.test.ts deleted file mode 100644 index f7f52bb..0000000 --- a/packages/api/src/lib/statistics/__tests__/location-filter.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import type { Database } from '../../../db/client' -import type { Database as SQLiteDatabase } from 'bun:sqlite' -import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' -import { - createTestBag, - createTestBox, - createTestLocation, - createTestMicronixPlate, - createTestMicronixTube, - createTestPaper, - createTestSheet, - createTestStaticWell, - createTestStorageContainer, - createTestStorageType, -} from '../../../__tests__/helpers/factories' -import { - resolveContainerIdsAtLocations, - resolveStatisticsLocationFilter, -} from '../location-filter' - -describe('location-filter', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) { - cleanupTestDatabase(sqlite) - } - }) - - it('resolveStatisticsLocationFilter returns none without location_id', async () => { - const result = await resolveStatisticsLocationFilter( - testDb, - sqlite as SQLiteDatabase, - undefined, - ) - expect(result).toEqual({ kind: 'none' }) - }) - - it('resolveStatisticsLocationFilter returns not_found for missing location', async () => { - const result = await resolveStatisticsLocationFilter( - testDb, - sqlite as SQLiteDatabase, - '99999', - ) - expect(result).toEqual({ kind: 'not_found' }) - }) - - it('resolveStatisticsLocationFilter returns resolved ids for an existing location', async () => { - const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) - const loc = await createTestLocation(testDb, { - name: 'Freezer A', - storageTypeId: String(storageType.id), - }) - - const result = await resolveStatisticsLocationFilter( - testDb, - sqlite as SQLiteDatabase, - String(loc.id), - ) - - expect(result).toEqual({ - kind: 'resolved', - filteredLocationIds: [loc.id], - }) - }) - - it('resolveContainerIdsAtLocations returns micronix container ids at matching locations', async () => { - const storageType = await createTestStorageType(testDb, { name: 'Shelf Storage' }) - const loc = await createTestLocation(testDb, { - name: 'Shelf 1', - storageTypeId: String(storageType.id), - }) - const otherStorageType = await createTestStorageType(testDb, { name: 'Other Storage' }) - const otherLoc = await createTestLocation(testDb, { - name: 'Shelf 2', - storageTypeId: String(otherStorageType.id), - }) - const plate = await createTestMicronixPlate(testDb, { name: 'Plate 1', locationId: loc.id }) - await createTestMicronixPlate(testDb, { name: 'Plate 2', locationId: otherLoc.id }) - - const container = await createTestStorageContainer(testDb) - await createTestMicronixTube(testDb, { - containerId: container.id, - collectionId: plate.id, - barcode: 'MX-LOC-1', - position: 'A01', - }) - - const ids = await resolveContainerIdsAtLocations(testDb, [loc.id]) - - expect(ids).toEqual([container.id]) - }) - - it('resolveContainerIdsAtLocations includes static wells and papers in boxes and bags', async () => { - const storageType = await createTestStorageType(testDb, { name: 'Freezer' }) - const loc = await createTestLocation(testDb, { name: 'Freezer A', storageTypeId: String(storageType.id) }) - const plate = await createTestMicronixPlate(testDb, { name: 'P1', locationId: loc.id }) - const well = await createTestStorageContainer(testDb) - await createTestStaticWell(testDb, { containerId: well.id, collectionId: plate.id, position: 'A01' }) - const boxRow = await createTestBox(testDb, { name: 'Box1', locationId: loc.id }) - const bagRow = await createTestBag(testDb, { name: 'Bag1', locationId: loc.id }) - const boxSheet = await createTestSheet(testDb, { name: 'S1', boxId: boxRow.id }) - const bagSheet = await createTestSheet(testDb, { name: 'S1', bagId: bagRow.id }) - const reuse = { specimenId: well.specimenId, unitId: well.unitId! } - const paperInBox = await createTestStorageContainer(testDb, reuse) - const paperInBag = await createTestStorageContainer(testDb, reuse) - await createTestPaper(testDb, { containerId: paperInBox.id, sheetId: boxSheet.id }) - await createTestPaper(testDb, { containerId: paperInBag.id, sheetId: bagSheet.id }) - - const ids = await resolveContainerIdsAtLocations(testDb, [loc.id]) - - expect(ids.sort((a, b) => a - b)).toEqual([well.id, paperInBox.id, paperInBag.id].sort((a, b) => a - b)) - }) -}) diff --git a/packages/api/src/lib/statistics/dashboard-stats.ts b/packages/api/src/lib/statistics/dashboard-stats.ts index a455a4c..2db5e84 100644 --- a/packages/api/src/lib/statistics/dashboard-stats.ts +++ b/packages/api/src/lib/statistics/dashboard-stats.ts @@ -1,23 +1,8 @@ +import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' -import type { Database as SQLiteDatabase } from 'bun:sqlite' -import { - specimen, - storageContainer, - studySubject, - study, - specimenType, - tag, - storageContainerTag, -} from '../../db/schema' -import { eq, and, sql, inArray } from 'drizzle-orm' -import { resolveContainerTypes } from '../container-placement' -import { buildDateFilter } from './helpers' -import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' +import { study } from '../../db/schema' +import { selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '../container-selection' import { computeContainerAggregates } from './container-aggregates' -import { - resolveContainerIdsAtLocations, - resolveStatisticsLocationFilter, -} from './location-filter' import { computeSpecimenAggregates } from './specimen-aggregates' import { computeStorageStatistics } from './storage-aggregates' import type { DashboardStatistics, StatisticsFilters } from './types' @@ -47,272 +32,79 @@ function emptyDashboardStatistics(): DashboardStatistics { } } +/** Parse an integer id query value; anything else means no filter. */ +function parseId(value: string | undefined): number | undefined { + if (!value) return undefined + const id = parseInt(value) + return isNaN(id) ? undefined : id +} + +/** Map the statistics query to Container selection filters (ADR 0011). */ +function selectionFilters(filters: StatisticsFilters): ContainerSelectionFilters { + const specimenTypeId = parseId(filters.specimen_type_id) + return { + specimenTypeIds: specimenTypeId === undefined ? undefined : [specimenTypeId], + collectionDate: { from: filters.collection_date_from, to: filters.collection_date_to }, + specimenCreated: { from: filters.created_from, to: filters.created_to }, + containerTypes: filters.container_type ? [filters.container_type] : undefined, + tagIds: filters.tag_ids, + locationId: parseId(filters.location_id), + } +} + +/** + * The selection the statistics query describes, or null when it matches nothing: an + * unknown Study, or control Specimens within a Study. An unknown Location or Tag + * matches nothing inside the selection. + */ +async function statisticsSelection( + database: Database, + filters: StatisticsFilters, +): Promise { + const sourceType = + filters.source_type === 'subject' || filters.source_type === 'control' ? filters.source_type : undefined + if (!filters.study) return { kind: 'database', sourceType, filters: selectionFilters(filters) } + if (sourceType === 'control') return null + const studyRecord = await database + .select({ id: study.id }) + .from(study) + .where(eq(study.shortCode, filters.study)) + .get() + if (!studyRecord) return null + return { kind: 'study', studyId: studyRecord.id, filters: selectionFilters(filters) } +} + /** Filtered dashboard statistics for specimens, containers, and storage. */ export async function getDashboardStatistics( database: Database, - sqliteDatabase: SQLiteDatabase, filters: StatisticsFilters, ): Promise { - const { - study: studyCode, - source_type: sourceType, - specimen_type_id: specimenTypeId, - container_type: containerType, - tag_ids: tagIds, - location_id: locationId, - collection_date_from: collectionDateFrom, - collection_date_to: collectionDateTo, - created_from: createdFrom, - created_to: createdTo, - } = filters - - // Build specimen filter conditions - const specimenConditions = [] - - // Filter by study, selecting its subjects with a subquery rather than an id list - if (studyCode) { - const studyRecord = await database - .select() - .from(study) - .where(eq(study.shortCode, studyCode)) - .get() - - // An unknown study matches nothing; it must not fall through to whole-database totals. - if (!studyRecord) { - return emptyDashboardStatistics() - } - const studySubjectIds = database - .select({ id: studySubject.id }) - .from(studySubject) - .where(eq(studySubject.studyId, studyRecord.id)) - const anySubject = await database - .select({ id: studySubject.id }) - .from(studySubject) - .where(eq(studySubject.studyId, studyRecord.id)) - .limit(1) - .get() - if (!anySubject) { - // No subjects in study, return empty results - return emptyDashboardStatistics() - } - specimenConditions.push(inArray(specimen.studySubjectId, studySubjectIds)) - } - - if (sourceType === 'subject') { - specimenConditions.push(sql`${specimen.studySubjectId} IS NOT NULL`) - } else if (sourceType === 'control') { - specimenConditions.push(sql`${specimen.controlBatchId} IS NOT NULL`) - } - - if (specimenTypeId) { - const typeId = parseInt(specimenTypeId) - if (!isNaN(typeId)) { - specimenConditions.push(eq(specimen.specimenTypeId, typeId)) - } - } - - // Date filters for specimens - const collectionDateFilters = buildDateFilter(specimen.collectionDate, collectionDateFrom, collectionDateTo) - const createdDateFilters = buildDateFilter(specimen.created, createdFrom, createdTo) - specimenConditions.push(...collectionDateFilters, ...createdDateFilters) - - // Get filtered specimens - let specimenQuery = database.select().from(specimen) - if (specimenConditions.length > 0) { - specimenQuery = specimenQuery.where(and(...specimenConditions) as any) as any - } - const filteredSpecimens = await specimenQuery - - const specimenIds = filteredSpecimens.map(s => s.id) - - let specimenAggregates = await computeSpecimenAggregates(database, filteredSpecimens, { - useSpecimenTypeCache: true, - }) - - // Location Filtering - Query matching location IDs and their descendants - const hasLocationFilter = !!locationId - const locationFilter = await resolveStatisticsLocationFilter(database, sqliteDatabase, locationId) - let filteredLocationIds: number[] = [] - - if (locationFilter.kind === 'not_found') { - return emptyDashboardStatistics() - } + const selection = await statisticsSelection(database, filters) + if (!selection) return emptyDashboardStatistics() + const { containers, specimens, containerTypes } = await selectContainers(database, selection) + if (specimens.length === 0) return emptyDashboardStatistics() + + const hasContainerFilter = + !!filters.container_type || (filters.tag_ids?.length ?? 0) > 0 || parseId(filters.location_id) !== undefined + const specimenAggregates = await computeSpecimenAggregates(database, specimens, { + useSpecimenTypeCache: !hasContainerFilter, + }) + const containerTotal = containers.length + const averagePerSpecimen = specimenAggregates.total > 0 ? containerTotal / specimenAggregates.total : 0 + const containerAggregates = await computeContainerAggregates(database, containers, containerTypes) + const storage = await computeStorageStatistics( + database, + containers.map((container) => container.id), + containerTotal, + ) - if (locationFilter.kind === 'resolved') { - filteredLocationIds = locationFilter.filteredLocationIds - } - - // Get container IDs filtered by tags if tag filter is provided - // Using AND logic: containers must have ALL selected tags - let tagFilteredContainerIds: number[] | null = null - if (tagIds && tagIds.length > 0) { - - // For AND logic, find containers that have all selected tags - // Query containers for each tag and find the intersection - const containerSets: Set[] = [] - - for (const tagId of tagIds) { - const containersWithTag = await database - .select({ containerId: storageContainerTag.storageContainerId }) - .from(storageContainerTag) - .where(eq(storageContainerTag.tagId, tagId)) - containerSets.push(new Set(containersWithTag.map(ct => ct.containerId))) - } - - // Find intersection: containers that appear in all sets - if (containerSets.length > 0) { - let intersection = containerSets[0] - for (let i = 1; i < containerSets.length; i++) { - intersection = new Set([...intersection].filter(id => containerSets[i].has(id))) - } - tagFilteredContainerIds = Array.from(intersection) - } else { - tagFilteredContainerIds = [] - } - - if (tagFilteredContainerIds.length === 0) { - // No containers match all the tags, return empty results - return emptyDashboardStatistics() - } - } - - // Container Statistics - // Use efficient joins when location filter is present - let filteredContainers: Array = [] - - if (specimenIds.length > 0) { - // Get location-filtered container IDs once (if location filter is active) - let locationFilteredContainerIds: number[] | null = null - if (hasLocationFilter && filteredLocationIds.length > 0) { - locationFilteredContainerIds = await resolveContainerIdsAtLocations( - database, - filteredLocationIds, - tagFilteredContainerIds, - ) - } - - const locationFilteredIdSet = locationFilteredContainerIds ? new Set(locationFilteredContainerIds) : null - const tagFilteredIdSet = tagFilteredContainerIds ? new Set(tagFilteredContainerIds) : null - - // Batch query if too many specimen IDs to avoid SQLite variable limit - // Use Promise.all to parallelize queries for better performance - const specimenChunks = chunkArray(specimenIds, SQL_IN_CHUNK_SIZE) - - const chunkResults = await Promise.all( - specimenChunks.map(async (specimenChunk, chunkIndex) => { - const containers = await database - .select() - .from(storageContainer) - .where(inArray(storageContainer.specimenId, specimenChunk)) - // Location and tag filters are applied in memory: as IN lists they can exceed - // SQLite's parameter limit on large locations. - return containers.filter( - (c) => - (!locationFilteredIdSet || locationFilteredIdSet.has(c.id)) && - (!tagFilteredIdSet || tagFilteredIdSet.has(c.id)), - ) - }) - ) - - // Flatten results - filteredContainers = chunkResults.flat() - - // Remove duplicates (in case a container appears in multiple chunks - shouldn't happen, but be safe) - const seenIds = new Set() - filteredContainers = filteredContainers.filter(c => { - if (seenIds.has(c.id)) { - return false - } - seenIds.add(c.id) - return true - }) - } else if (filteredSpecimens.length === 0) { - // No specimens match, so no containers - return emptyDashboardStatistics() - } else { - // No specimen filter, but we might have state/status/location filters - if (hasLocationFilter && filteredLocationIds.length > 0) { - const locationFilteredContainerIds = await resolveContainerIdsAtLocations( - database, - filteredLocationIds, - tagFilteredContainerIds, - ) - - if (locationFilteredContainerIds.length === 0) { - filteredContainers = [] - } else { - // Fetch containers matching location and other filters - // Use Promise.all to parallelize queries - const containerIdChunks = chunkArray(locationFilteredContainerIds, SQL_IN_CHUNK_SIZE) - const chunkResults = await Promise.all( - containerIdChunks.map(async (containerIdChunk) => { - // Tag filter is already intersected into locationFilteredContainerIds. - return await database - .select() - .from(storageContainer) - .where(inArray(storageContainer.id, containerIdChunk)) - }) - ) - filteredContainers = chunkResults.flat() - } - } else { - // No location filter - if (tagFilteredContainerIds) { - const tagIdChunks = chunkArray(tagFilteredContainerIds, SQL_IN_CHUNK_SIZE) - const chunkResults = await Promise.all( - tagIdChunks.map((idChunk) => - database.select().from(storageContainer).where(inArray(storageContainer.id, idChunk)), - ), - ) - filteredContainers = chunkResults.flat() - } else { - filteredContainers = await database.select().from(storageContainer) - } - } - } - - // Get container types in batch - const containerIds = filteredContainers.map(c => c.id) - const containerTypeMap = await resolveContainerTypes(database, containerIds) - - // Filter by container type if specified (before calculating totals) - let finalContainers = filteredContainers - if (containerType) { - finalContainers = filteredContainers.filter(container => - containerTypeMap.get(container.id) === containerType - ) - } - - const containerTotal = finalContainers.length - const finalContainerIds = finalContainers.map((c) => c.id) - - if (containerType || tagFilteredContainerIds || hasLocationFilter) { - const filteredSpecimenIds = new Set(finalContainers.map(c => c.specimenId)) - const adjustedSpecimens = filteredSpecimens.filter((s) => filteredSpecimenIds.has(s.id)) - - specimenAggregates = await computeSpecimenAggregates(database, adjustedSpecimens, { - useSpecimenTypeCache: false, - }) - } - - const averagePerSpecimen = - specimenAggregates.total > 0 ? containerTotal / specimenAggregates.total : 0 - - const containerAggregates = await computeContainerAggregates( - database, - finalContainers, - containerTypeMap, - ) - - const storage = await computeStorageStatistics(database, finalContainerIds, containerTotal) - - return { - specimens: specimenAggregates, - containers: { - total: containerTotal, - ...containerAggregates, - averagePerSpecimen: Math.round(averagePerSpecimen * 100) / 100, - }, - storage, - } + return { + specimens: specimenAggregates, + containers: { + total: containerTotal, + ...containerAggregates, + averagePerSpecimen: Math.round(averagePerSpecimen * 100) / 100, + }, + storage, + } } diff --git a/packages/api/src/lib/statistics/helpers.ts b/packages/api/src/lib/statistics/helpers.ts deleted file mode 100644 index 1b7e3a1..0000000 --- a/packages/api/src/lib/statistics/helpers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { gte } from 'drizzle-orm' -import type { SQLiteColumn } from 'drizzle-orm/sqlite-core' -import { dateToUpperBound } from '../sql-helpers' - -/** Build date filter conditions for specimen date columns. */ -export function buildDateFilter(column: SQLiteColumn, dateFrom?: string, dateTo?: string) { - const conditions: ReturnType[] = [] - if (dateFrom) { - conditions.push(gte(column, dateFrom)) - } - if (dateTo) { - conditions.push(dateToUpperBound(column, dateTo)) - } - return conditions -} diff --git a/packages/api/src/lib/statistics/location-filter.ts b/packages/api/src/lib/statistics/location-filter.ts deleted file mode 100644 index 9a83f43..0000000 --- a/packages/api/src/lib/statistics/location-filter.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { Database } from '../../db/client' -import type { Database as SQLiteDatabase } from 'bun:sqlite' -import { - location, - micronixPlate, - micronixTube, - staticWell, - cryovialBox, - cryovialTube, - box, - bag, - sheet, - paper, -} from '../../db/schema' -import { eq, inArray, or } from 'drizzle-orm' - -export type StatisticsLocationFilterResult = - | { kind: 'none' } - | { kind: 'not_found' } - | { kind: 'resolved'; filteredLocationIds: number[] } - -/** Resolve a location_id filter to the target location and its descendants. */ -export async function resolveStatisticsLocationFilter( - database: Database, - sqliteDatabase: SQLiteDatabase, - locationId?: string, -): Promise { - if (!locationId) { - return { kind: 'none' } - } - - const id = parseInt(locationId) - if (isNaN(id)) { - return { kind: 'none' } - } - - const targetLocation = await database.select().from(location).where(eq(location.id, id)).get() - if (!targetLocation) { - return { kind: 'not_found' } - } - - const { getLocationDescendants } = await import('../location-helpers') - const descendants = await getLocationDescendants(sqliteDatabase, id) - - return { - kind: 'resolved', - filteredLocationIds: [id, ...descendants.map((d) => d.id)], - } -} - -/** - * Resolve storage container ids stored in the given locations: tubes and static wells on - * micronix plates, tubes in cryovial boxes, and papers on sheets in boxes or bags. - * Collections are matched with subqueries so large locations never build huge IN lists. - */ -export async function resolveContainerIdsAtLocations( - database: Database, - filteredLocationIds: number[], - tagFilteredContainerIds?: number[] | null, -): Promise { - const platesHere = database - .select({ id: micronixPlate.id }) - .from(micronixPlate) - .where(inArray(micronixPlate.locationId, filteredLocationIds)) - const cryovialBoxesHere = database - .select({ id: cryovialBox.id }) - .from(cryovialBox) - .where(inArray(cryovialBox.locationId, filteredLocationIds)) - const sheetsHere = database - .select({ id: sheet.id }) - .from(sheet) - .where( - or( - inArray(sheet.boxId, database.select({ id: box.id }).from(box).where(inArray(box.locationId, filteredLocationIds))), - inArray(sheet.bagId, database.select({ id: bag.id }).from(bag).where(inArray(bag.locationId, filteredLocationIds))), - ), - ) - - const [micronix, wells, cryovial, papers] = await Promise.all([ - database.select({ id: micronixTube.id }).from(micronixTube).where(inArray(micronixTube.collectionId, platesHere)), - database.select({ id: staticWell.id }).from(staticWell).where(inArray(staticWell.collectionId, platesHere)), - database.select({ id: cryovialTube.id }).from(cryovialTube).where(inArray(cryovialTube.collectionId, cryovialBoxesHere)), - database.select({ id: paper.id }).from(paper).where(inArray(paper.sheetId, sheetsHere)), - ]) - - const ids = new Set([...micronix, ...wells, ...cryovial, ...papers].map((r) => r.id)) - if (tagFilteredContainerIds) { - const tagged = new Set(tagFilteredContainerIds) - return [...ids].filter((id) => tagged.has(id)) - } - return [...ids] -} diff --git a/packages/api/src/routes/__tests__/statistics.test.ts b/packages/api/src/routes/__tests__/statistics.test.ts index 2f20d71..e927c04 100644 --- a/packages/api/src/routes/__tests__/statistics.test.ts +++ b/packages/api/src/routes/__tests__/statistics.test.ts @@ -17,8 +17,8 @@ describe('Statistics API', () => { password: 'password123', role: 'admin', }, - mount: (app, { db, sqlite }) => { - app.route('/api/statistics', createStatisticsRoutes(db, sqlite)) + mount: (app, { db }) => { + app.route('/api/statistics', createStatisticsRoutes(db)) }, }) }) diff --git a/packages/api/src/routes/statistics.ts b/packages/api/src/routes/statistics.ts index 54c3043..6648ec5 100644 --- a/packages/api/src/routes/statistics.ts +++ b/packages/api/src/routes/statistics.ts @@ -1,18 +1,13 @@ import { Hono } from 'hono' import type { Database } from '../db/client' -import type { Database as SQLiteDatabase } from 'bun:sqlite' import { getAdminStatistics } from '../lib/statistics/admin-stats' import { getDashboardStatistics } from '../lib/statistics/dashboard-stats' import { parseStatisticsFilters } from '../lib/statistics/parse-filters' import { handleRouteError } from '../lib/error-handler' import { createAdminMiddleware, createAuthMiddleware } from '../middleware/auth' -/** - * Create statistics routes with database injection - * @param database - Database instance (required) - * @param sqliteDatabase - Raw SQLite database instance (required for location queries) - */ -export function createStatisticsRoutes(database: Database, sqliteDatabase: SQLiteDatabase) { +/** Create statistics routes with database injection. */ +export function createStatisticsRoutes(database: Database) { const statistics = new Hono() const authMiddleware = createAuthMiddleware(database) const adminMiddleware = createAdminMiddleware(database) @@ -20,7 +15,7 @@ export function createStatisticsRoutes(database: Database, sqliteDatabase: SQLit statistics.get('/', authMiddleware, async (c) => { try { const filters = parseStatisticsFilters(c) - const data = await getDashboardStatistics(database, sqliteDatabase, filters) + const data = await getDashboardStatistics(database, filters) return c.json(data) } catch (error: unknown) { return handleRouteError(error, c) From ac28c650994686cab667d5a24c4893c33fcc2fca Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 16:54:09 -0700 Subject: [PATCH 78/85] refactor(export): select multi-study and barcode exports through Container selection. A multi-study upload is now one subject-list selection instead of one buildContainerQuery call per Study, followed by a container type filter and enrichment per Study. The executor groups the enriched rows by Study for the Export summary. The barcode export selects the found tubes with a micronix barcode selection and applies no other filters; found and not found still come from resolveMicronixBarcodesToContainers, since the selection carries no barcodes. buildMultiStudyContainerQuery, buildContainerQueryByMicronixBarcodes and MultiStudyExportResult had no other callers and are deleted. The multi-visit test moves to the executor tests. Co-Authored-By: Claude Opus 5.5 --- .../export/__tests__/container-export.test.ts | 22 ++ .../src/lib/export/__tests__/query.test.ts | 76 ----- .../api/src/lib/export/container-export.ts | 263 ++++++++++++------ packages/api/src/lib/export/query.ts | 253 +---------------- packages/api/src/lib/export/types.ts | 6 - 5 files changed, 203 insertions(+), 417 deletions(-) diff --git a/packages/api/src/lib/export/__tests__/container-export.test.ts b/packages/api/src/lib/export/__tests__/container-export.test.ts index 5c2a169..6185eea 100644 --- a/packages/api/src/lib/export/__tests__/container-export.test.ts +++ b/packages/api/src/lib/export/__tests__/container-export.test.ts @@ -191,6 +191,28 @@ describe('Container export executors', () => { expect(containerIds(result.data)).toEqual([alphaJan.id]) }) + it('keeps every listed visit of a Subject', async () => { + const study = await createTestStudy(db, { title: 'Visits', shortCode: 'VIS' }) + const subject = await createTestStudySubject(db, { studyId: study.id, name: 'S01' }) + const jan = await seedTube((await seedSpecimen({ studySubjectId: subject.id }, '2024-01-01')).id) + const feb = await seedTube((await seedSpecimen({ studySubjectId: subject.id }, '2024-02-03')).id) + await seedTube((await seedSpecimen({ studySubjectId: subject.id }, '2024-03-01')).id) + + const result = await runMultiStudyContainerExportPost(db, { + entries: [ + { study_short_code: 'VIS', subject_name: 'S01', collection_date: '2024-01-01' }, + { study_short_code: 'VIS', subject_name: 'S01', collection_date: '2024-02-01' }, + ], + filters: {}, + format: 'json', + columns, + dateTolerance: 2, + }) + + if (!('data' in result)) throw new Error('expected an export envelope') + expect(containerIds(result.data)).toEqual([jan.id, feb.id]) + }) + it('counts with the same summary as the export', async () => { await seedTwoStudies() diff --git a/packages/api/src/lib/export/__tests__/query.test.ts b/packages/api/src/lib/export/__tests__/query.test.ts index f12399e..58bcd93 100644 --- a/packages/api/src/lib/export/__tests__/query.test.ts +++ b/packages/api/src/lib/export/__tests__/query.test.ts @@ -15,9 +15,7 @@ import { utcNow } from '../../datetime' import { eq } from 'drizzle-orm' import { buildContainerQuery, - buildMultiStudyContainerQuery, resolveMicronixBarcodesToContainers, - buildContainerQueryByMicronixBarcodes, } from '../query' describe('buildContainerQuery', () => { @@ -212,77 +210,3 @@ describe('resolveMicronixBarcodesToContainers', () => { expect(result.size).toBe(0) }) }) - -describe('buildContainerQueryByMicronixBarcodes', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) cleanupTestDatabase(sqlite) - }) - - it('returns empty result for empty container ids', async () => { - const result = await buildContainerQueryByMicronixBarcodes(testDb, []) - expect(result.containers).toHaveLength(0) - expect(result.specimens).toHaveLength(0) - expect(result.studies).toHaveLength(0) - expect(result.subjectToStudyMap.size).toBe(0) - }) -}) - - -describe('buildMultiStudyContainerQuery subject dates', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) cleanupTestDatabase(sqlite) - }) - - it('keeps every listed visit per subject and includes subjects listed without a date', async () => { - const study = await createTestStudy(testDb, { title: 'Visits', shortCode: 'VIS', leadPerson: 'L' }) - const s01 = await createTestStudySubject(testDb, { studyId: study.id, name: 'S01' }) - const s02 = await createTestStudySubject(testDb, { studyId: study.id, name: 'S02' }) - const type = await createTestSpecimenType(testDb, { name: 'Blood' }) - const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' }) - const now = utcNow() - const addSpecimen = async (subjectId: number, collectionDate: string) => { - const [row] = await testDb - .insert(specimenTable) - .values({ studySubjectId: subjectId, specimenTypeId: type.id, collectionDate, created: now, lastUpdated: now }) - .returning() - await createTestStorageContainer(testDb, { specimenId: row.id, unitId: unit.id }) - return row - } - const jan = await addSpecimen(s01.id, '2024-01-01') - const feb = await addSpecimen(s01.id, '2024-02-01') - await addSpecimen(s01.id, '2024-03-01') - const may = await addSpecimen(s02.id, '2024-05-05') - - const result = await buildMultiStudyContainerQuery( - testDb, - [ - { study_short_code: 'VIS', subject_name: 'S01', collection_date: '2024-01-01' }, - { study_short_code: 'VIS', subject_name: 'S01', collection_date: '2024-02-01' }, - { study_short_code: 'VIS', subject_name: 'S02' }, - ], - {}, - ) - - expect(result.containers.map((c) => c.specimen_id).sort((a, b) => a - b)).toEqual( - [jan.id, feb.id, may.id].sort((a, b) => a - b), - ) - }) -}) diff --git a/packages/api/src/lib/export/container-export.ts b/packages/api/src/lib/export/container-export.ts index ede5bc5..e109cdb 100644 --- a/packages/api/src/lib/export/container-export.ts +++ b/packages/api/src/lib/export/container-export.ts @@ -1,12 +1,24 @@ -import type { ContainerExportData } from '@sampledb/contract' +import { subjectDateSelections, type ContainerExportData } from '@sampledb/contract' +import { eq, inArray } from 'drizzle-orm' import type { Database } from '../../db/client' +import { study, studySubject } from '../../db/schema' import { ExpectedNotFoundError, RouteError } from '../error-handler' import { enrichContainerData } from './enrich' import { filterContainerIdsByType } from './filter' import { formatAsCSV, formatAsExcel, formatAsJSON } from './format' -import { buildContainerQuery, buildContainerQueryByMicronixBarcodes, buildMultiStudyContainerQuery, resolveMicronixBarcodesToContainers } from './query' +import { buildContainerQuery, resolveMicronixBarcodesToContainers } from './query' import { buildExportSummary, validateStudyCodes } from './validate' -import { resolveSubjectNamesByStudy, resolveStudyByShortCode } from '../identifier-resolution' +import { + resolveSubjectNamesByStudy, + resolveStudyByShortCode, + resolveSubjectsByStudyGrouped, +} from '../identifier-resolution' +import { + selectContainers, + type ContainerSelectionFilters, + type SubjectSelectionEntry, +} from '../container-selection' +import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' import type { CSVExportOptions, ExportFilters, @@ -316,6 +328,33 @@ export async function runSingleStudyContainerExportPost( }) } +/** Placeholder study for export metadata when an export spans Studies or has none (ADR 0011). */ +const MULTI_STUDY_RECORD: StudyRecord = { + id: 0, + shortCode: 'MULTI', + title: 'Multi-Study Export', + description: null, + leadPerson: '', + isLongitudinal: false, + created: '', + lastUpdated: '', + createdBy: null, + updatedBy: null, +} + +/** Container selection filters for an export's filters; `created_*` means Container created (ADR 0011). */ +function exportSelectionFilters( + filters: Omit +): ContainerSelectionFilters { + return { + specimenTypeIds: filters.specimen_type_ids, + collectionDate: { from: filters.date_from, to: filters.date_to }, + containerCreated: { from: filters.created_from, to: filters.created_to }, + containerTypes: filters.container_types, + tagIds: filters.tag_ids, + } +} + export async function runMultiStudyContainerExportPost( database: Database, params: { @@ -330,45 +369,140 @@ export async function runMultiStudyContainerExportPost( } ): Promise { const { entries, filters, format, columns, csvOptions, userId, countOnly, dateTolerance = 0 } = params - const result = await buildMultiStudyContainerQuery(database, entries, filters, dateTolerance) + const filenameBase = `multi_study_export_${formatExportTimestamp()}` - if (countOnly) { - return { count: result.containers.length, summary: result.summary } + const validation = await validateStudyCodes( + database, + entries.map((entry) => entry.study_short_code) + ) + if (validation.invalid.length > 0) { + const summary: MultiStudyExportSummary = { + total_containers: 0, + studies: [], + invalid_study_codes: validation.invalid, + errors: [`Invalid study codes: ${validation.invalid.join(', ')}`], + } + if (countOnly) return { count: 0, summary } + const formatted = await formatContainerExport( + database, + [], + format, + { study: 'MULTI' }, + MULTI_STUDY_RECORD, + columns, + csvOptions, + userId + ) + return buildPostExportEnvelope({ format, filenameBase, summary, ...formatted }) } - const firstStudy = - result.studies.size > 0 - ? Array.from(result.studies.values())[0] - : ({ - id: 0, - shortCode: 'MULTI', - title: 'Multi-Study Export', - description: null, - leadPerson: '', - isLongitudinal: false, - created: '', - lastUpdated: '', - createdBy: null, - updatedBy: null, - } as StudyRecord) + const entriesByStudy = new Map() + for (const entry of entries) { + const studyId = validation.valid.get(entry.study_short_code)! + entriesByStudy.set(studyId, [...(entriesByStudy.get(studyId) ?? []), entry]) + } + const subjectsByStudy = await resolveSubjectsByStudyGrouped( + database, + entries.map((entry) => ({ + studyId: validation.valid.get(entry.study_short_code)!, + subjectName: entry.subject_name, + })) + ) + + // The whole upload is one subject-list selection. A Subject listed without a date matches + // every collection date. + const subjects: SubjectSelectionEntry[] = [] + const subjectToStudyMap = new Map() + for (const [studyId, studyEntries] of entriesByStudy) { + const subjectIds = subjectsByStudy.get(studyId) ?? new Map() + for (const [subjectName, selection] of subjectDateSelections(studyEntries)) { + const subjectId = subjectIds.get(subjectName) + if (subjectId === undefined) continue + subjects.push({ studyId, subjectId, dates: selection.kind === 'dates' ? selection.filters : undefined }) + subjectToStudyMap.set(subjectId, validation.studies.get(studyId)!) + } + } + + const selected = + subjects.length > 0 + ? await selectContainers(database, { + kind: 'subjects', + subjects, + dateTolerance, + filters: exportSelectionFilters(filters), + }) + : { containers: [], specimens: [] } + const enrichedData = await enrichContainerData( + database, + selected.containers, + selected.specimens, + MULTI_STUDY_RECORD, + undefined, + subjectToStudyMap + ) + + // The Export summary groups the one selection by Study. + const studySummaries: MultiStudyExportSummary['studies'] = [] + for (const [studyId, studyEntries] of entriesByStudy) { + const studyRecord = validation.studies.get(studyId)! + const subjectNameToId = subjectsByStudy.get(studyId) ?? new Map() + const subjectIdToName = new Map([...subjectNameToId].map(([name, id]) => [id, name])) + const studyData = enrichedData.filter((row) => row.study_id === studyId) + const studySummary = await buildExportSummary( + studyData, + studyEntries.map((entry) => entry.subject_name), + subjectNameToId, + subjectIdToName + ) + studySummaries.push({ + study_code: studyRecord.shortCode, + study_title: studyRecord.title, + study_lead_person: studyRecord.leadPerson, + containers: studyData.length, + subjects_with_results: studySummary.subjects_with_results, + subjects_no_results: studySummary.subjects_no_results, + subjects_not_found: studySummary.subjects_not_found, + }) + } + const summary: MultiStudyExportSummary = { + total_containers: enrichedData.length, + studies: studySummaries, + invalid_study_codes: [], + } + + if (countOnly) { + return { count: enrichedData.length, summary } + } const formatted = await formatContainerExport( database, - result.containers, + enrichedData, format, { study: 'MULTI' }, - firstStudy, + validation.studies.values().next().value ?? MULTI_STUDY_RECORD, columns, csvOptions, userId ) - return buildPostExportEnvelope({ - format, - filenameBase: `multi_study_export_${formatExportTimestamp()}`, - summary: result.summary, - ...formatted, - }) + return buildPostExportEnvelope({ format, filenameBase, summary, ...formatted }) +} + +/** Each Subject's Study, by Subject id. */ +async function loadStudiesBySubject( + database: Database, + subjectIds: number[] +): Promise> { + const studiesBySubject = new Map() + for (const chunk of chunkArray([...new Set(subjectIds)], SQL_IN_CHUNK_SIZE)) { + const rows = await database + .select({ subjectId: studySubject.id, study }) + .from(studySubject) + .innerJoin(study, eq(studySubject.studyId, study.id)) + .where(inArray(studySubject.id, chunk)) + for (const row of rows) studiesBySubject.set(row.subjectId, row.study) + } + return studiesBySubject } export async function runBarcodeContainerExportPost( @@ -382,73 +516,30 @@ export async function runBarcodeContainerExportPost( } ): Promise { const { barcodes, format, columns, csvOptions, userId } = params + // Selected Containers carry no barcode, so found and not found come from the tubes. const barcodeToContainerId = await resolveMicronixBarcodesToContainers(database, barcodes) - const containerIds = Array.from(barcodeToContainerId.values()) const foundBarcodes = Array.from(barcodeToContainerId.keys()) - const notFoundBarcodes = barcodes.filter((b) => !barcodeToContainerId.has(b)) - - const summary = { - total_containers: 0, - barcodes_found: foundBarcodes, - barcodes_not_found: notFoundBarcodes, - } - - if (containerIds.length === 0) { - return buildPostExportEnvelope({ - format, - filenameBase: `barcode_export_${formatExportTimestamp()}`, - summary, - jsonData: [], - csv: '', - xlsx: Buffer.from(''), - }) - } - - const queryResult = await buildContainerQueryByMicronixBarcodes(database, containerIds) - if (queryResult.containers.length === 0) { - return buildPostExportEnvelope({ - format, - filenameBase: `barcode_export_${formatExportTimestamp()}`, - summary, - jsonData: [], - csv: '', - xlsx: Buffer.from(''), - }) - } - - const firstStudy = - queryResult.studies.length > 0 - ? queryResult.studies[0] - : ({ - id: 0, - shortCode: 'MULTI', - title: 'Multi-Study Export', - description: null, - leadPerson: '', - isLongitudinal: false, - created: '', - lastUpdated: '', - createdBy: null, - updatedBy: null, - } as StudyRecord) + const selected = await selectContainers(database, { kind: 'micronixBarcodes', barcodes: foundBarcodes }) + const studiesBySubject = await loadStudiesBySubject( + database, + selected.specimens.flatMap((row) => (row.studySubjectId == null ? [] : [row.studySubjectId])) + ) const enrichedData = await enrichContainerData( database, - queryResult.containers, - queryResult.specimens, - firstStudy, + selected.containers, + selected.specimens, + MULTI_STUDY_RECORD, undefined, - queryResult.subjectToStudyMap + studiesBySubject ) - summary.total_containers = enrichedData.length - const formatted = await formatContainerExport( database, enrichedData, format, { study: 'MULTI' }, - firstStudy, + studiesBySubject.values().next().value ?? MULTI_STUDY_RECORD, columns, csvOptions, userId @@ -457,7 +548,11 @@ export async function runBarcodeContainerExportPost( return buildPostExportEnvelope({ format, filenameBase: `barcode_export_${formatExportTimestamp()}`, - summary, + summary: { + total_containers: enrichedData.length, + barcodes_found: foundBarcodes, + barcodes_not_found: barcodes.filter((barcode) => !barcodeToContainerId.has(barcode)), + }, ...formatted, }) } diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index 5ff12d0..0679873 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -7,20 +7,9 @@ import { micronixTube, } from '../../db/schema' import { eq, and, or, inArray, gte, lte, sql, type SQL } from 'drizzle-orm' -import { resolveSubjectsByStudyGrouped } from '../identifier-resolution' import { resolveContainerIdsWithAllTags } from '../container-tag-filter' -import { enrichContainerData } from './enrich' -import { filterContainerIdsByType } from './filter' -import { subjectDateSelections, type SubjectDateFilter, type SubjectDateSelection } from '@sampledb/contract' -import type { - ContainerExportData, - ExportFilters, - MultiStudyExportEntry, - MultiStudyExportResult, - MultiStudyExportSummary, - StudyRecord, -} from './types' -import { buildExportSummary, validateStudyCodes } from './validate' +import type { SubjectDateFilter } from '@sampledb/contract' +import type { ExportFilters } from './types' import { chunkArray, dateToUpperBound, SQL_IN_CHUNK_SIZE } from '../sql-helpers' import { shiftCalendarDate } from '../datetime' @@ -187,84 +176,6 @@ export async function resolveMicronixBarcodesToContainers( return result } -/** - * Build container query by micronix container IDs (multi-study support) - */ -export async function buildContainerQueryByMicronixBarcodes( - database: Database, - containerIds: number[] -): Promise<{ - containers: Array - specimens: Array<{ id: number; studySubjectId: number | null; controlBatchId: number | null; specimenTypeId: number; collectionDate: string | null; created: string }> - studies: StudyRecord[] - subjectToStudyMap: Map -}> { - if (containerIds.length === 0) { - return { containers: [], specimens: [], studies: [], subjectToStudyMap: new Map() } - } - - // Get containers - const containers = await database - .select() - .from(storageContainer) - .where(inArray(storageContainer.id, containerIds)) - - if (containers.length === 0) { - return { containers: [], specimens: [], studies: [], subjectToStudyMap: new Map() } - } - - // Get specimens for these containers - // specimenId can be null from DB; filter to numbers only - const specimenIds = [...new Set(containers.map(c => c.specimenId).filter((id): id is number => typeof id === 'number'))] - if (specimenIds.length === 0) { - return { containers, specimens: [], studies: [], subjectToStudyMap: new Map() } - } - - const specimens = await database - .select({ - id: specimen.id, - studySubjectId: specimen.studySubjectId, - controlBatchId: specimen.controlBatchId, - specimenTypeId: specimen.specimenTypeId, - collectionDate: specimen.collectionDate, - created: specimen.created, - }) - .from(specimen) - .where(inArray(specimen.id, specimenIds)) - - // Get unique study IDs from subjects - const subjectIds = [...new Set(specimens.filter(s => s.studySubjectId !== null).map(s => s.studySubjectId!))] - const studies: StudyRecord[] = [] - const subjectToStudyMap = new Map() - - if (subjectIds.length > 0) { - const subjects = await database - .select({ id: studySubject.id, studyId: studySubject.studyId }) - .from(studySubject) - .where(inArray(studySubject.id, subjectIds)) - - const uniqueStudyIds = [...new Set(subjects.map(s => s.studyId))] - if (uniqueStudyIds.length > 0) { - const studyRecords = await database - .select() - .from(study) - .where(inArray(study.id, uniqueStudyIds)) - studies.push(...studyRecords) - - // Build subject to study map - const studyMap = new Map(studyRecords.map(s => [s.id, s])) - for (const subject of subjects) { - const studyRecord = studyMap.get(subject.studyId) - if (studyRecord) { - subjectToStudyMap.set(subject.id, studyRecord) - } - } - } - } - - return { containers, specimens, studies, subjectToStudyMap } -} - /** Collection date condition for one per-subject filter (exact date +/- tolerance days, or a range). */ function subjectDateCondition(dateFilter: SubjectDateFilter, toleranceDays: number) { if ('exact' in dateFilter) { @@ -282,163 +193,3 @@ function subjectDateCondition(dateFilter: SubjectDateFilter, toleranceDays: numb ] return bounds.length > 0 ? and(...bounds) : null } - -export async function buildMultiStudyContainerQuery( - database: Database, - entries: MultiStudyExportEntry[], - filters: Omit, - dateTolerance: number = 0 -): Promise { - // Validate all study codes - const studyCodes = entries.map(e => e.study_short_code) - const validation = await validateStudyCodes(database, studyCodes) - - if (validation.invalid.length > 0) { - // Return early with invalid study codes - return { - containers: [], - studies: validation.studies, - summary: { - total_containers: 0, - studies: [], - invalid_study_codes: validation.invalid, - errors: [`Invalid study codes: ${validation.invalid.join(', ')}`], - }, - } - } - - // Group entries by study - const entriesByStudy = new Map() - for (const entry of entries) { - const studyId = validation.valid.get(entry.study_short_code) - if (studyId) { - if (!entriesByStudy.has(studyId)) { - entriesByStudy.set(studyId, []) - } - entriesByStudy.get(studyId)!.push(entry) - } - } - - // Resolve subjects for each study - const subjectResolutionEntries: Array<{ studyId: number; subjectName: string }> = [] - for (const [studyId, studyEntries] of entriesByStudy.entries()) { - for (const entry of studyEntries) { - subjectResolutionEntries.push({ studyId, subjectName: entry.subject_name }) - } - } - - const subjectsByStudy = await resolveSubjectsByStudyGrouped(database, subjectResolutionEntries) - - // Date selection per subject name, for studies where at least one subject is limited to some dates - const subjectDatesByStudy = new Map>() - for (const [studyId, studyEntries] of entriesByStudy.entries()) { - const selections = subjectDateSelections(studyEntries) - if ([...selections.values()].some((selection) => selection.kind === 'dates')) { - subjectDatesByStudy.set(studyId, selections) - } - } - - // Process each study separately - const allContainers: ContainerExportData[] = [] - const studySummaries: MultiStudyExportSummary['studies'] = [] - - for (const [studyId, studyEntries] of entriesByStudy.entries()) { - const studyRecord = validation.studies.get(studyId)! - const studyCode = studyRecord.shortCode - const subjectMap = subjectsByStudy.get(studyId) || new Map() - const subjectIds = Array.from(subjectMap.values()) - - if (subjectIds.length === 0) { - // No subjects found for this study - studySummaries.push({ - study_code: studyCode, - study_title: studyRecord.title, - study_lead_person: studyRecord.leadPerson, - containers: 0, - subjects_with_results: [], - subjects_no_results: [], - subjects_not_found: studyEntries.map(e => e.subject_name), - }) - continue - } - - // Build filters for this study - const studyFilters: ExportFilters = { - study: studyCode, - subject_ids: subjectIds, - date_tolerance: dateTolerance, - ...filters, - } - - // Add subject dates if available - const studySubjectDates = subjectDatesByStudy.get(studyId) - if (studySubjectDates && studySubjectDates.size > 0) { - studyFilters.subject_dates = {} - for (const [subjectName, selection] of studySubjectDates.entries()) { - const subjectId = subjectMap.get(subjectName) - // Subjects with no entry match every date. - if (subjectId && selection.kind === 'dates') { - studyFilters.subject_dates[subjectId] = selection.filters - } - } - } - - // Query containers for this study - const { containers, specimens } = await buildContainerQuery(database, studyFilters) - - // Apply container type filter if specified - let filteredContainers = containers - if (filters.container_types && filters.container_types.length > 0) { - const containerIds = containers.map(c => c.id) - const matchingIds = await filterContainerIdsByType(database, containerIds, filters.container_types) - filteredContainers = containers.filter(c => matchingIds.includes(c.id)) - } - - // Enrich container data - const enrichedData = await enrichContainerData( - database, - filteredContainers, - specimens || [], - studyRecord, - filters.container_types, - undefined - ) - - allContainers.push(...enrichedData) - - // Build summary for this study - const subjectIdToName = new Map() - for (const [name, id] of subjectMap.entries()) { - subjectIdToName.set(id, name) - } - - const studySummary = await buildExportSummary( - enrichedData, - studyEntries.map(e => e.subject_name), - subjectMap, - subjectIdToName - ) - - studySummaries.push({ - study_code: studyCode, - study_title: studyRecord.title, - study_lead_person: studyRecord.leadPerson, - containers: enrichedData.length, - subjects_with_results: studySummary.subjects_with_results, - subjects_no_results: studySummary.subjects_no_results, - subjects_not_found: studySummary.subjects_not_found, - }) - } - - return { - containers: allContainers, - studies: validation.studies, - summary: { - total_containers: allContainers.length, - studies: studySummaries, - invalid_study_codes: validation.invalid, - }, - } -} - - diff --git a/packages/api/src/lib/export/types.ts b/packages/api/src/lib/export/types.ts index 9d73324..1f89325 100644 --- a/packages/api/src/lib/export/types.ts +++ b/packages/api/src/lib/export/types.ts @@ -22,12 +22,6 @@ export interface MultiStudyExportEntry { date_to?: string } -export interface MultiStudyExportResult { - containers: import('@sampledb/contract').ContainerExportData[] - studies: Map - summary: MultiStudyExportSummary -} - export interface MultiStudyExportSummary { total_containers: number studies: Array<{ From 57cd93a5b39e3e7004440c1f671cf288fed6dc54 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:08:01 -0700 Subject: [PATCH 79/85] refactor(export): take export study fields from the Source and delete the old selection code. enrichContainerData now takes only the Containers. Each subject row's study fields come from its Source's Study (ADR 0007) and control rows have none, so the type filter, the unused specimen list, the study argument and the subject-to-Study map are gone, along with loadStudiesBySubject. The MULTI study record is now used only for JSON export metadata, including the barcode export's. buildContainerQuery, filterContainerIdsByType (lib/export/filter.ts) and lib/container-tag-filter.ts had no callers outside their tests and are deleted with them; Container selection's tests cover their cases. query.ts keeps resolveMicronixBarcodesToContainers. Co-Authored-By: Claude Opus 5.5 --- packages/api/src/lib/container-tag-filter.ts | 32 --- .../src/lib/export/__tests__/enrich.test.ts | 262 +++--------------- .../src/lib/export/__tests__/filter.test.ts | 88 ------ .../src/lib/export/__tests__/query.test.ts | 180 +----------- .../api/src/lib/export/container-export.ts | 54 +--- packages/api/src/lib/export/enrich.ts | 40 +-- packages/api/src/lib/export/filter.ts | 41 --- packages/api/src/lib/export/query.ts | 170 +----------- 8 files changed, 66 insertions(+), 801 deletions(-) delete mode 100644 packages/api/src/lib/container-tag-filter.ts delete mode 100644 packages/api/src/lib/export/__tests__/filter.test.ts delete mode 100644 packages/api/src/lib/export/filter.ts diff --git a/packages/api/src/lib/container-tag-filter.ts b/packages/api/src/lib/container-tag-filter.ts deleted file mode 100644 index c02007c..0000000 --- a/packages/api/src/lib/container-tag-filter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { eq } from 'drizzle-orm' -import type { Database } from '../db/client' -import { storageContainerTag } from '../db/schema' - -/** - * Resolve container IDs that have ALL of the given tags (AND semantics). - * Returns empty array when no containers match. - */ -export async function resolveContainerIdsWithAllTags( - database: Database, - tagIds: number[], -): Promise { - if (tagIds.length === 0) return [] - - const containerSets: Set[] = [] - for (const tagId of tagIds) { - const rows = await database - .select({ containerId: storageContainerTag.storageContainerId }) - .from(storageContainerTag) - .where(eq(storageContainerTag.tagId, tagId)) - containerSets.push(new Set(rows.map((row) => row.containerId))) - } - - if (containerSets.length === 0) return [] - - let intersection = containerSets[0] - for (let i = 1; i < containerSets.length; i++) { - intersection = new Set([...intersection].filter((id) => containerSets[i].has(id))) - } - - return Array.from(intersection) -} diff --git a/packages/api/src/lib/export/__tests__/enrich.test.ts b/packages/api/src/lib/export/__tests__/enrich.test.ts index 2ec3b4e..bed9c17 100644 --- a/packages/api/src/lib/export/__tests__/enrich.test.ts +++ b/packages/api/src/lib/export/__tests__/enrich.test.ts @@ -16,12 +16,9 @@ import { createTestTag, } from '../../../__tests__/helpers/factories' import type { Database } from '../../../db/client' -import { micronixTube, cryovialTube, cryovialBox, storageContainerTag, box, sheet, paper } from '../../../db/schema' -import { utcNow } from '../../datetime' +import { micronixTube, storageContainerTag, box, sheet, paper } from '../../../db/schema' import { utcNow } from '../../datetime' import { enrichContainerData } from '../enrich' -import { buildContainerQuery } from '../query' -import type { StudyRecord } from '../types' describe('enrichContainerData', () => { let testDb: Database @@ -78,21 +75,7 @@ describe('enrichContainerData', () => { const plate = await createTestMicronixPlate(testDb, { name: 'Plate-1', locationId: loc.id }) const container = await createMicronixContainer(testDb, spec.id, plate, 'MT-001', 'A01') - const enriched = await enrichContainerData( - testDb, - [container], - [ - { - id: spec.id, - studySubjectId: subject.id, - controlBatchId: null, - specimenTypeId: specimenType.id, - collectionDate: '2024-06-01', - created: spec.created, - }, - ], - study as StudyRecord, - ) + const enriched = await enrichContainerData(testDb, [container]) expect(enriched).toHaveLength(1) expect(enriched[0]).toMatchObject({ @@ -125,10 +108,6 @@ describe('enrichContainerData', () => { const batch = await createTestControlBatch(testDb, definition.id, { name: 'Batch-June' }) const specimenType = await createTestSpecimenType(testDb, { name: 'Control blood' }) const spec = await createTestSpecimen(testDb, specimenType.id, { controlBatchId: batch.id }) - const placeholderStudy = await createTestStudy(testDb, { - title: 'Unused', - shortCode: 'UNUSED', - }) const unit = await createTestUnit(testDb, { symbol: `uL-ctrl-${Date.now()}`, name: 'microliter', @@ -136,21 +115,7 @@ describe('enrichContainerData', () => { }) const container = await createTestStorageContainer(testDb, { specimenId: spec.id, unitId: unit.id }) - const enriched = await enrichContainerData( - testDb, - [container], - [ - { - id: spec.id, - studySubjectId: null, - controlBatchId: batch.id, - specimenTypeId: specimenType.id, - collectionDate: null, - created: spec.created, - }, - ], - placeholderStudy as StudyRecord, - ) + const enriched = await enrichContainerData(testDb, [container]) expect(enriched).toHaveLength(1) expect(enriched[0]).toMatchObject({ @@ -169,161 +134,50 @@ describe('enrichContainerData', () => { expect(enriched[0].strain_composition).toContain('100%') }) - it('filters out container types not in container_types', async () => { - const study = await createTestStudy(testDb, { - title: 'Filter Study', - shortCode: 'FILT', - }) - const subject = await createTestStudySubject(testDb, { studyId: study.id, name: 'Subj' }) - const specimenType = await createTestSpecimenType(testDb, { name: 'Serum' }) - const spec = await createTestSpecimen(testDb, specimenType.id, { studySubjectId: subject.id }) - const storageType = await createTestStorageType(testDb, { name: 'Shelf' }) - const loc = await createTestLocation(testDb, { - name: 'Loc', - storageTypeId: String(storageType.id), - canContainCollections: true, - }) - const plate = await createTestMicronixPlate(testDb, { name: 'P-mic', locationId: loc.id }) - const micronix = await createMicronixContainer(testDb, spec.id, plate, 'MIC-1', 'A01') - - const unit = await createTestUnit(testDb, { - symbol: `uL-cv-${Date.now()}`, - name: 'microliter', - category: 'volume', - }) - const cryoContainer = await createTestStorageContainer(testDb, { specimenId: spec.id, unitId: unit.id }) - const now = utcNow() - const [box] = await testDb - .insert(cryovialBox) - .values({ name: 'CryoBox', locationId: loc.id, created: now, lastUpdated: now }) - .returning() - await testDb.insert(cryovialTube).values({ - id: cryoContainer.id, - collectionId: box!.id, - barcode: 'CRYO-1', - position: null, - }) - - const specimenRow = { - id: spec.id, - studySubjectId: subject.id, - controlBatchId: null, - specimenTypeId: specimenType.id, - collectionDate: null, - created: spec.created, - } - - const enriched = await enrichContainerData( - testDb, - [micronix, cryoContainer], - [specimenRow], - study as StudyRecord, - ['micronix_tube'], - ) - - expect(enriched).toHaveLength(1) - expect(enriched[0].container_type).toBe('micronix_tube') - expect(enriched[0].container_id).toBe(micronix.id) - }) - - it('uses subjectToStudyMap for per-subject study fields in multi-study export', async () => { - const studyA = await createTestStudy(testDb, { - title: 'Study Alpha', - shortCode: 'ALPHA', - leadPerson: 'Alpha Lead', - }) - const studyB = await createTestStudy(testDb, { - title: 'Study Beta', - shortCode: 'BETA', - leadPerson: 'Beta Lead', - }) + it("takes each row's study fields from its Source's Study, and none for a control row", async () => { + const studyA = await createTestStudy(testDb, { title: 'Study Alpha', shortCode: 'ALPHA', leadPerson: 'Alpha Lead' }) + const studyB = await createTestStudy(testDb, { title: 'Study Beta', shortCode: 'BETA', leadPerson: 'Beta Lead' }) const subjectA = await createTestStudySubject(testDb, { studyId: studyA.id, name: 'A1' }) const subjectB = await createTestStudySubject(testDb, { studyId: studyB.id, name: 'B1' }) + const definition = await createTestControlDefinition(testDb, { name: 'Mix', controlType: 'blood' }) + const batch = await createTestControlBatch(testDb, definition.id, { name: 'Batch-1' }) const specimenType = await createTestSpecimenType(testDb, { name: 'Plasma' }) const specA = await createTestSpecimen(testDb, specimenType.id, { studySubjectId: subjectA.id }) const specB = await createTestSpecimen(testDb, specimenType.id, { studySubjectId: subjectB.id }) - const unit = await createTestUnit(testDb, { - symbol: `uL-ms-${Date.now()}`, - name: 'microliter', - category: 'volume', - }) - const containerA = await createTestStorageContainer(testDb, { specimenId: specA.id, unitId: unit.id }) - const containerB = await createTestStorageContainer(testDb, { specimenId: specB.id, unitId: unit.id }) - - const subjectToStudyMap = new Map([ - [subjectA.id, studyA as StudyRecord], - [subjectB.id, studyB as StudyRecord], - ]) - - const enriched = await enrichContainerData( - testDb, - [containerA, containerB], - [ - { - id: specA.id, - studySubjectId: subjectA.id, - controlBatchId: null, - specimenTypeId: specimenType.id, - collectionDate: null, - created: specA.created, - }, - { - id: specB.id, - studySubjectId: subjectB.id, - controlBatchId: null, - specimenTypeId: specimenType.id, - collectionDate: null, - created: specB.created, - }, - ], - studyA as StudyRecord, - undefined, - subjectToStudyMap, - ) - - expect(enriched).toHaveLength(2) - const rowA = enriched.find((r) => r.subject_name === 'A1') - const rowB = enriched.find((r) => r.subject_name === 'B1') - expect(rowA?.study_code).toBe('ALPHA') - expect(rowB?.study_code).toBe('BETA') - }) - - describe('pipeline with buildContainerQuery', () => { - it('enriches containers returned from buildContainerQuery', async () => { - const study = await createTestStudy(testDb, { - title: 'Pipeline Study', - shortCode: 'PIPE', - leadPerson: 'Pipe Lead', - }) - const subject = await createTestStudySubject(testDb, { studyId: study.id, name: 'PipeSubj' }) - const specimenType = await createTestSpecimenType(testDb, { name: 'Whole blood' }) - const spec = await createTestSpecimen(testDb, specimenType.id, { studySubjectId: subject.id }) - const storageType = await createTestStorageType(testDb, { name: 'Cold' }) - const loc = await createTestLocation(testDb, { - name: 'Rack 1', - storageTypeId: String(storageType.id), - canContainCollections: true, - }) - const plate = await createTestMicronixPlate(testDb, { name: 'PipePlate', locationId: loc.id }) - await createMicronixContainer(testDb, spec.id, plate, 'PIPE-01', 'B02') - - const { study: resolvedStudy, containers, specimens } = await buildContainerQuery(testDb, { - study: 'PIPE', - }) - const enriched = await enrichContainerData( - testDb, - containers, - specimens!, - resolvedStudy, - ) - - expect(enriched).toHaveLength(1) - expect(enriched[0]).toMatchObject({ - subject_name: 'PipeSubj', - study_code: 'PIPE', - specimen_type: 'Whole blood', - barcode: 'PIPE-01', - }) + const specControl = await createTestSpecimen(testDb, specimenType.id, { controlBatchId: batch.id }) + const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' }) + const containers = [ + await createTestStorageContainer(testDb, { specimenId: specA.id, unitId: unit.id }), + await createTestStorageContainer(testDb, { specimenId: specB.id, unitId: unit.id }), + await createTestStorageContainer(testDb, { specimenId: specControl.id, unitId: unit.id }), + ] + + const enriched = await enrichContainerData(testDb, containers) + + const studyFields = (row: (typeof enriched)[number] | undefined) => ({ + study_id: row?.study_id, + study_code: row?.study_code, + study_title: row?.study_title, + study_lead_person: row?.study_lead_person, + }) + expect(enriched).toHaveLength(3) + expect(studyFields(enriched.find((row) => row.subject_name === 'A1'))).toEqual({ + study_id: studyA.id, + study_code: 'ALPHA', + study_title: 'Study Alpha', + study_lead_person: 'Alpha Lead', + }) + expect(studyFields(enriched.find((row) => row.subject_name === 'B1'))).toEqual({ + study_id: studyB.id, + study_code: 'BETA', + study_title: 'Study Beta', + study_lead_person: 'Beta Lead', + }) + expect(studyFields(enriched.find((row) => row.control_batch_id === batch.id))).toEqual({ + study_id: undefined, + study_code: undefined, + study_title: undefined, + study_lead_person: undefined, }) }) @@ -359,21 +213,7 @@ describe('enrichContainerData', () => { sublabel: 'Spot-7', }) - const enriched = await enrichContainerData( - testDb, - [container], - [ - { - id: spec.id, - studySubjectId: subject.id, - controlBatchId: null, - specimenTypeId: specimenType.id, - collectionDate: '2024-06-01', - created: spec.created, - }, - ], - study as StudyRecord, - ) + const enriched = await enrichContainerData(testDb, [container]) expect(enriched).toHaveLength(1) expect(enriched[0]).toMatchObject({ @@ -403,21 +243,7 @@ describe('enrichContainerData', () => { { storageContainerId: container.id, tagId: qcTag.id }, ]) - const enriched = await enrichContainerData( - testDb, - [container], - [ - { - id: spec.id, - studySubjectId: subject.id, - controlBatchId: null, - specimenTypeId: specimenType.id, - collectionDate: null, - created: spec.created, - }, - ], - study, - ) + const enriched = await enrichContainerData(testDb, [container]) expect(enriched).toHaveLength(1) expect(enriched[0].tags).toBe('Hold, QC') diff --git a/packages/api/src/lib/export/__tests__/filter.test.ts b/packages/api/src/lib/export/__tests__/filter.test.ts deleted file mode 100644 index 133c564..0000000 --- a/packages/api/src/lib/export/__tests__/filter.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' -import { - createTestLocation, - createTestMicronixPlate, - createTestSpecimen, - createTestSpecimenType, - createTestStorageType, - createTestUnit, -} from '../../../__tests__/helpers/factories' -import type { Database } from '../../../db/client' -import { filterContainerIdsByType } from '../filter' -import { micronixTube, storageContainer } from '../../../db/schema' -import { utcNow } from '../../datetime' - -describe('filterContainerIdsByType', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) cleanupTestDatabase(sqlite) - }) - - it('returns all container ids when no filter', async () => { - const ids = [1, 2, 3] - const result = await filterContainerIdsByType(testDb, ids) - expect(result).toEqual(ids) - }) - - it('returns all container ids when empty filter array', async () => { - const ids = [1, 2] - const result = await filterContainerIdsByType(testDb, ids, []) - expect(result).toEqual(ids) - }) - - it('returns empty array when filter specified but no matching containers', async () => { - const ids: number[] = [] - const result = await filterContainerIdsByType(testDb, ids, ['micronix_tube']) - expect(result).toEqual([]) - }) - - it('filters to micronix tubes via placement reads', async () => { - const unit = await createTestUnit(testDb, { - symbol: `uL-filter-${Date.now()}`, - name: 'microliter', - category: 'volume', - }) - const specimenType = await createTestSpecimenType(testDb, { name: 'Blood' }) - const specimen = await createTestSpecimen(testDb, specimenType.id) - const now = utcNow() - const [container] = await testDb - .insert(storageContainer) - .values({ - specimenId: specimen.id, - unitId: unit.id, - totalQuantity: 1, - remainingQuantity: 1, - created: now, - lastUpdated: now, - }) - .returning() - - const storageType = await createTestStorageType(testDb, { name: `Lab-${container!.id}` }) - const location = await createTestLocation(testDb, { - name: `Lab-${container!.id}`, - storageTypeId: String(storageType.id), - }) - const plate = await createTestMicronixPlate(testDb, { - name: `Plate-${container!.id}`, - locationId: location.id, - }) - await testDb.insert(micronixTube).values({ - id: container!.id, - collectionId: plate.id, - barcode: `MX-${container!.id}`, - position: 'A01', - }) - - const result = await filterContainerIdsByType(testDb, [container!.id, 99999], ['micronix_tube']) - expect(result).toEqual([container!.id]) - }) -}) diff --git a/packages/api/src/lib/export/__tests__/query.test.ts b/packages/api/src/lib/export/__tests__/query.test.ts index 58bcd93..3578002 100644 --- a/packages/api/src/lib/export/__tests__/query.test.ts +++ b/packages/api/src/lib/export/__tests__/query.test.ts @@ -1,185 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' -import { - createTestStudy, - createTestStudySubject, - createTestSpecimenType, - createTestSpecimen, - createTestStorageContainer, - createTestTag, - createTestUnit, -} from '../../../__tests__/helpers/factories' import type { Database } from '../../../db/client' -import { storageContainerTag, specimen as specimenTable } from '../../../db/schema' -import { utcNow } from '../../datetime' -import { eq } from 'drizzle-orm' -import { - buildContainerQuery, - resolveMicronixBarcodesToContainers, -} from '../query' - -describe('buildContainerQuery', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) cleanupTestDatabase(sqlite) - }) - - it('returns containers and study for study with subject and specimen', async () => { - const study = await createTestStudy(testDb, { - title: 'Export Study', - shortCode: 'EX1', - leadPerson: 'Lead', - }) - const subject = await createTestStudySubject(testDb, { - studyId: study.id, - name: 'Subject1', - }) - const specimenType = await createTestSpecimenType(testDb, { name: 'Blood' }) - const specimen = await createTestSpecimen(testDb, specimenType.id, { - studySubjectId: subject.id, - }) - await createTestStorageContainer(testDb, { specimenId: specimen.id }) - - const result = await buildContainerQuery(testDb, { study: 'EX1' }) - expect(result.study.shortCode).toBe('EX1') - expect(result.containers).toHaveLength(1) - expect(result.specimens).toBeDefined() - expect(result.specimens!.length).toBe(1) - }) - - it('throws for unknown study short code', async () => { - await expect( - buildContainerQuery(testDb, { study: 'UNKNOWN' }) - ).rejects.toThrow('not found') - }) - - it('returns empty containers when study has no specimens', async () => { - const study = await createTestStudy(testDb, { - title: 'Empty Study', - shortCode: 'EMPTY2', - leadPerson: 'X', - }) - await createTestStudySubject(testDb, { studyId: study.id, name: 'S1' }) - const result = await buildContainerQuery(testDb, { study: 'EMPTY2' }) - expect(result.containers).toHaveLength(0) - expect(result.study.shortCode).toBe('EMPTY2') - }) - - it('returns empty containers when study has no subjects', async () => { - const study = await createTestStudy(testDb, { - title: 'Empty New Study', - shortCode: 'EMPTY_NEW', - leadPerson: 'X', - }) - // No subjects created - const result = await buildContainerQuery(testDb, { study: 'EMPTY_NEW' }) - expect(result.containers).toHaveLength(0) - expect(result.study.shortCode).toBe('EMPTY_NEW') - }) - - it('returns only containers that have all selected tags (AND)', async () => { - const study = await createTestStudy(testDb, { - title: 'Tag Filter Study', - shortCode: 'TF1', - leadPerson: 'Lead', - }) - const subject = await createTestStudySubject(testDb, { studyId: study.id, name: 'Subj1' }) - const specimenType = await createTestSpecimenType(testDb, { name: 'Blood' }) - const spec = await createTestSpecimen(testDb, specimenType.id, { studySubjectId: subject.id }) - - const unit = await createTestUnit(testDb, { - symbol: `uL-tag-${Date.now()}`, - name: 'microliter', - category: 'volume', - }) - const bothTagsContainer = await createTestStorageContainer(testDb, { specimenId: spec.id, unitId: unit.id }) - const oneTagContainer = await createTestStorageContainer(testDb, { specimenId: spec.id, unitId: unit.id }) - - const qcTag = await createTestTag(testDb, { name: 'QC' }) - const holdTag = await createTestTag(testDb, { name: 'Hold' }) - - await testDb.insert(storageContainerTag).values([ - { storageContainerId: bothTagsContainer.id, tagId: qcTag.id }, - { storageContainerId: bothTagsContainer.id, tagId: holdTag.id }, - { storageContainerId: oneTagContainer.id, tagId: qcTag.id }, - ]) - - const result = await buildContainerQuery(testDb, { - study: 'TF1', - tag_ids: [qcTag.id, holdTag.id], - }) - - expect(result.containers).toHaveLength(1) - expect(result.containers[0].id).toBe(bothTagsContainer.id) - }) -}) - -describe('buildContainerQuery for studies with more subjects than SQLite allows as bound parameters', () => { - let testDb: Database - let sqlite: Awaited>['sqlite'] - const count = 70_000 - - beforeEach(async () => { - const setup = await setupTestDatabase() - testDb = setup.db - sqlite = setup.sqlite - }) - - afterEach(() => { - if (sqlite) cleanupTestDatabase(sqlite) - }) - - /** 70,000 subjects; the first three have one dated specimen and container each. */ - async function seedBigStudy() { - const study = await createTestStudy(testDb, { title: 'Big Study', shortCode: 'BIG' }) - const type = await createTestSpecimenType(testDb, { name: 'DBS' }) - const subjectIds: number[] = [] - sqlite.exec('BEGIN') - const addSubject = sqlite.prepare( - "INSERT INTO study_subject (study_id, name, created, last_updated) VALUES (?, ?, '2024-01-01', '2024-01-01')", - ) - for (let i = 0; i < count; i++) { - subjectIds.push(Number(addSubject.run(study.id, `S${i}`).lastInsertRowid)) - } - sqlite.exec('COMMIT') - const unit = await createTestUnit(testDb, { symbol: 'spots', name: 'spots', category: 'count' }) - for (const [i, subjectId] of subjectIds.slice(0, 3).entries()) { - const spec = await createTestSpecimen(testDb, type.id, { studySubjectId: subjectId }) - await testDb.update(specimenTable).set({ collectionDate: `2024-0${i + 1}-01` }).where(eq(specimenTable.id, spec.id)) - await createTestStorageContainer(testDb, { specimenId: spec.id, unitId: unit.id }) - } - return subjectIds - } - - it('exports the whole study', async () => { - await seedBigStudy() - - const result = await buildContainerQuery(testDb, { study: 'BIG' }) - - expect(result.containers).toHaveLength(3) - }, 60_000) - - it('exports a listed subject set with per-subject dates', async () => { - const subjectIds = await seedBigStudy() - - const result = await buildContainerQuery(testDb, { - study: 'BIG', - subject_ids: subjectIds, - subject_dates: { [subjectIds[0]]: [{ exact: '2024-01-01' }], [subjectIds[1]]: [{ exact: '2030-01-01' }] }, - }) - - // Subject 0 matches its date, subject 1 does not, subject 2 is listed without a date. - expect(result.specimens?.map((s) => s.studySubjectId).sort((a, b) => a! - b!)).toEqual([subjectIds[0], subjectIds[2]]) - }, 60_000) -}) +import { resolveMicronixBarcodesToContainers } from '../query' describe('resolveMicronixBarcodesToContainers', () => { let testDb: Database diff --git a/packages/api/src/lib/export/container-export.ts b/packages/api/src/lib/export/container-export.ts index 20fa5dd..c874e0c 100644 --- a/packages/api/src/lib/export/container-export.ts +++ b/packages/api/src/lib/export/container-export.ts @@ -1,7 +1,7 @@ import { subjectDateSelections, type ContainerExportData, type SubjectDateFilter } from '@sampledb/contract' -import { eq, inArray } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' -import { study, studySubject } from '../../db/schema' +import { study } from '../../db/schema' import { selectContainers, type ContainerSelection, @@ -14,7 +14,6 @@ import { formatAsCSV, formatAsExcel, formatAsJSON } from './format' import { resolveMicronixBarcodesToContainers } from './query' import { buildExportSummary, validateStudyCodes } from './validate' import { resolveSubjectNamesByStudy, resolveSubjectsByStudyGrouped } from '../identifier-resolution' -import { chunkArray, SQL_IN_CHUNK_SIZE } from '../sql-helpers' import type { CSVExportOptions, ExportFilters, @@ -178,7 +177,7 @@ export async function runSingleStudyContainerExportGet( throw new RouteError(400, { error: 'Invalid format. Use csv, xlsx, or json.' }) } - const enrichedData = await enrichContainerData(database, result.containers, result.specimens, studyRecord) + const enrichedData = await enrichContainerData(database, result.containers) const timestamp = formatExportTimestamp() const filenameBase = `study_${studyRecord.shortCode}_export_${timestamp}` @@ -287,7 +286,7 @@ export async function runSingleStudyContainerExportPost( return { count: result.containers.length, summary } } - const enrichedData = await enrichContainerData(database, result.containers, result.specimens, studyRecord) + const enrichedData = await enrichContainerData(database, result.containers) const summary = await buildExportSummary( enrichedData, @@ -386,14 +385,12 @@ export async function runMultiStudyContainerExportPost( // The whole upload is one subject-list selection. A Subject listed without a date matches // every collection date. const subjects: SubjectSelectionEntry[] = [] - const subjectToStudyMap = new Map() for (const [studyId, studyEntries] of entriesByStudy) { const subjectIds = subjectsByStudy.get(studyId) ?? new Map() for (const [subjectName, selection] of subjectDateSelections(studyEntries)) { const subjectId = subjectIds.get(subjectName) if (subjectId === undefined) continue subjects.push({ studyId, subjectId, dates: selection.kind === 'dates' ? selection.filters : undefined }) - subjectToStudyMap.set(subjectId, validation.studies.get(studyId)!) } } @@ -405,15 +402,8 @@ export async function runMultiStudyContainerExportPost( dateTolerance, filters: exportSelectionFilters(filters), }) - : { containers: [], specimens: [] } - const enrichedData = await enrichContainerData( - database, - selected.containers, - selected.specimens, - MULTI_STUDY_RECORD, - undefined, - subjectToStudyMap - ) + : { containers: [] } + const enrichedData = await enrichContainerData(database, selected.containers) // The Export summary groups the one selection by Study. const studySummaries: MultiStudyExportSummary['studies'] = [] @@ -462,23 +452,6 @@ export async function runMultiStudyContainerExportPost( return buildPostExportEnvelope({ format, filenameBase, summary, ...formatted }) } -/** Each Subject's Study, by Subject id. */ -async function loadStudiesBySubject( - database: Database, - subjectIds: number[] -): Promise> { - const studiesBySubject = new Map() - for (const chunk of chunkArray([...new Set(subjectIds)], SQL_IN_CHUNK_SIZE)) { - const rows = await database - .select({ subjectId: studySubject.id, study }) - .from(studySubject) - .innerJoin(study, eq(studySubject.studyId, study.id)) - .where(inArray(studySubject.id, chunk)) - for (const row of rows) studiesBySubject.set(row.subjectId, row.study) - } - return studiesBySubject -} - export async function runBarcodeContainerExportPost( database: Database, params: { @@ -495,25 +468,14 @@ export async function runBarcodeContainerExportPost( const foundBarcodes = Array.from(barcodeToContainerId.keys()) const selected = await selectContainers(database, { kind: 'micronixBarcodes', barcodes: foundBarcodes }) - const studiesBySubject = await loadStudiesBySubject( - database, - selected.specimens.flatMap((row) => (row.studySubjectId == null ? [] : [row.studySubjectId])) - ) - const enrichedData = await enrichContainerData( - database, - selected.containers, - selected.specimens, - MULTI_STUDY_RECORD, - undefined, - studiesBySubject - ) + const enrichedData = await enrichContainerData(database, selected.containers) const formatted = await formatContainerExport( database, enrichedData, format, { study: 'MULTI' }, - studiesBySubject.values().next().value ?? MULTI_STUDY_RECORD, + MULTI_STUDY_RECORD, columns, csvOptions, userId diff --git a/packages/api/src/lib/export/enrich.ts b/packages/api/src/lib/export/enrich.ts index ba0db52..330a05e 100644 --- a/packages/api/src/lib/export/enrich.ts +++ b/packages/api/src/lib/export/enrich.ts @@ -5,15 +5,15 @@ import { projectContainerPlacementFields, } from '../container-projection' import { loadContainerReadViews } from '../container-read-view' -import type { ContainerExportData, StudyRecord } from './types' +import type { ContainerExportData } from './types' +/** + * Container export rows for the selected Containers. Study fields come from each + * Specimen's Source (ADR 0007); a control row has none. + */ export async function enrichContainerData( database: Database, - containers: StorageContainer[], - _specimens: Array<{ id: number; studySubjectId: number | null; controlBatchId: number | null; specimenTypeId: number; collectionDate: string | null; created: string }>, - study: StudyRecord, - containerTypeFilter?: string[], - subjectToStudyMap?: Map + containers: StorageContainer[] ): Promise { const views = await loadContainerReadViews(database, containers) const enriched: ContainerExportData[] = [] @@ -38,27 +38,9 @@ export async function enrichContainerData( const controlBatchId = spec.controlBatchId || undefined const source = view.source const controlSource = source?.type === 'control' ? source : undefined + const subjectStudy = source?.type === 'subject' ? source.study : undefined const subjectName = source?.name - if (containerTypeFilter && containerTypeFilter.length > 0 && !containerTypeFilter.includes(containerType)) { - continue - } - - let containerStudy = study - if (subjectToStudyMap && subjectId) { - const subjectStudy = subjectToStudyMap.get(subjectId) - if (subjectStudy) { - containerStudy = subjectStudy - } - } else if (controlBatchId) { - containerStudy = study - } - - const studyId = controlBatchId ? undefined : containerStudy.id - const studyTitle = controlBatchId ? undefined : containerStudy.title - const studyCode = controlBatchId ? undefined : containerStudy.shortCode - const studyLeadPerson = controlBatchId ? undefined : containerStudy.leadPerson - const tagNames = [...view.tags.map((t) => t.name)].sort((a, b) => a.localeCompare(b)) enriched.push({ @@ -89,10 +71,10 @@ export async function enrichContainerData( target_density: controlSource?.targetDensity ?? undefined, target_density_unit: controlSource?.targetDensityUnit ?? undefined, strain_composition: controlSource?.strainComposition ?? undefined, - study_id: studyId, - study_title: studyTitle, - study_code: studyCode, - study_lead_person: studyLeadPerson, + study_id: subjectStudy?.id, + study_title: subjectStudy?.title, + study_code: subjectStudy?.code, + study_lead_person: subjectStudy?.leadPerson, location_path: view.placement.locationPath, location_id: view.placement.location?.id, location_name: view.placement.location?.name, diff --git a/packages/api/src/lib/export/filter.ts b/packages/api/src/lib/export/filter.ts deleted file mode 100644 index 27fcbff..0000000 --- a/packages/api/src/lib/export/filter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Database } from '../../db/client' -import type { ContainerSubtype } from '../container-placement' -import { resolveContainerTypes } from '../container-placement' - -const EXPORTABLE_SUBTYPES: ContainerSubtype[] = [ - 'micronix_tube', - 'cryovial_tube', - 'paper', - 'static_well', -] - -/** Filter container IDs to those matching export container_types via placement reads. */ -export async function filterContainerIdsByType( - database: Database, - containerIds: number[], - containerTypeFilter?: string[], -): Promise { - if (!containerTypeFilter || containerTypeFilter.length === 0) { - return containerIds - } - - if (containerIds.length === 0) { - return [] - } - - const allowedTypes = new Set( - containerTypeFilter.filter((type): type is ContainerSubtype => - EXPORTABLE_SUBTYPES.includes(type as ContainerSubtype), - ), - ) - - if (allowedTypes.size === 0) { - return [] - } - - const typeMap = await resolveContainerTypes(database, containerIds) - return containerIds.filter((id) => { - const containerType = typeMap.get(id) - return containerType !== undefined && allowedTypes.has(containerType) - }) -} diff --git a/packages/api/src/lib/export/query.ts b/packages/api/src/lib/export/query.ts index 0679873..50c755a 100644 --- a/packages/api/src/lib/export/query.ts +++ b/packages/api/src/lib/export/query.ts @@ -1,154 +1,6 @@ +import { inArray } from 'drizzle-orm' import type { Database } from '../../db/client' -import { - storageContainer, - specimen, - studySubject, - study, - micronixTube, -} from '../../db/schema' -import { eq, and, or, inArray, gte, lte, sql, type SQL } from 'drizzle-orm' -import { resolveContainerIdsWithAllTags } from '../container-tag-filter' -import type { SubjectDateFilter } from '@sampledb/contract' -import type { ExportFilters } from './types' -import { chunkArray, dateToUpperBound, SQL_IN_CHUNK_SIZE } from '../sql-helpers' -import { shiftCalendarDate } from '../datetime' - -export async function buildContainerQuery(database: Database, filters: ExportFilters) { - // First, get the study and its subjects - const studyRecord = await database - .select() - .from(study) - .where(eq(study.shortCode, filters.study)) - .get() - - if (!studyRecord) { - throw new Error(`Study with short code '${filters.study}' not found`) - } - - // A subject list (or per-subject dates) needs the study's subject ids; a whole-study export - // selects them with a subquery instead of an id list. - const studySubjectIds = database - .select({ id: studySubject.id }) - .from(studySubject) - .where(eq(studySubject.studyId, studyRecord.id)) - - const subjectDates = - filters.subject_dates && Object.keys(filters.subject_dates).length > 0 ? filters.subject_dates : undefined - const listsSubjects = (filters.subject_ids && filters.subject_ids.length > 0) || subjectDates !== undefined - - let filteredSubjectIds: number[] = [] - if (listsSubjects) { - filteredSubjectIds = (await studySubjectIds).map((s) => s.id) - if (filters.subject_ids && filters.subject_ids.length > 0) { - const requested = new Set(filters.subject_ids) - filteredSubjectIds = filteredSubjectIds.filter((id) => requested.has(id)) - } - if (filteredSubjectIds.length === 0) { - return { containers: [], study: studyRecord, specimens: [] } - } - } - - const specimenConditions: SQL[] = [sql`${specimen.studySubjectId} IS NOT NULL`] - if (filters.specimen_type_ids && filters.specimen_type_ids.length > 0) { - specimenConditions.push(inArray(specimen.specimenTypeId, filters.specimen_type_ids)) - } - const globalDateConditions = [ - ...(filters.date_from ? [gte(specimen.collectionDate, filters.date_from)] : []), - ...(filters.date_to ? [lte(specimen.collectionDate, filters.date_to)] : []), - ] - - /** Subject (and collection date) condition for one chunk of listed subjects. */ - const subjectChunkCondition = (subjectIds: number[]): SQL | undefined => { - if (!subjectDates) return and(inArray(specimen.studySubjectId, subjectIds), ...globalDateConditions) - const tolerance = filters.date_tolerance || 0 - const conditions: SQL[] = [] - const undatedSubjectIds: number[] = [] - for (const subjectId of subjectIds) { - const entry = subjectDates[subjectId] as SubjectDateFilter | SubjectDateFilter[] | undefined - const dateConditions = (entry == null ? [] : Array.isArray(entry) ? entry : [entry]) - .map((dateFilter) => subjectDateCondition(dateFilter, tolerance)) - .filter((condition) => condition != null) - if (dateConditions.length === 0) { - // Listed without a date: every collection date for this subject. - undatedSubjectIds.push(subjectId) - continue - } - // Several entries for one subject (one per visit) are alternatives. - conditions.push(and(eq(specimen.studySubjectId, subjectId), or(...dateConditions))!) - } - if (undatedSubjectIds.length > 0) { - conditions.push(and(inArray(specimen.studySubjectId, undatedSubjectIds), ...globalDateConditions)!) - } - return or(...conditions) - } - - // Listed subjects are queried a chunk at a time, so neither the IN list nor the per-subject - // OR grows with the upload. - const subjectConditions = listsSubjects - ? chunkArray(filteredSubjectIds, SQL_IN_CHUNK_SIZE).map(subjectChunkCondition) - : [and(inArray(specimen.studySubjectId, studySubjectIds), ...globalDateConditions)] - - const specimens: Array<{ - id: number - studySubjectId: number | null - controlBatchId: number | null - specimenTypeId: number - collectionDate: string | null - created: string - }> = [] - for (const subjectCondition of subjectConditions) { - specimens.push( - ...(await database - .select({ - id: specimen.id, - studySubjectId: specimen.studySubjectId, - controlBatchId: specimen.controlBatchId, - specimenTypeId: specimen.specimenTypeId, - collectionDate: specimen.collectionDate, - created: specimen.created, - }) - .from(specimen) - .where(and(...specimenConditions, subjectCondition))), - ) - } - - const specimenIds = specimens.map(s => s.id) - if (specimenIds.length === 0) { - return { containers: [], study: studyRecord } - } - - // Build conditions for container query - const containerConditions: any[] = [] - - if (filters.created_from) { - containerConditions.push(gte(storageContainer.created, filters.created_from)) - } - - if (filters.created_to) { - containerConditions.push(dateToUpperBound(storageContainer.created, filters.created_to)) - } - - // Get matching containers, chunking specimen ids to stay under SQLite's parameter limit - let containers: Array = [] - for (const idChunk of chunkArray(specimenIds, SQL_IN_CHUNK_SIZE)) { - containers.push( - ...(await database - .select() - .from(storageContainer) - .where(and(inArray(storageContainer.specimenId, idChunk), ...containerConditions) as any)), - ) - } - - if (filters.tag_ids && filters.tag_ids.length > 0) { - const matchingIds = new Set(await resolveContainerIdsWithAllTags(database, filters.tag_ids)) - if (matchingIds.size === 0) { - return { containers: [], study: studyRecord, specimens: [] } - } - containers = containers.filter((container) => matchingIds.has(container.id)) - } - - return { containers, study: studyRecord, specimens } -} +import { micronixTube } from '../../db/schema' /** * Resolve micronix barcodes to container IDs @@ -175,21 +27,3 @@ export async function resolveMicronixBarcodesToContainers( return result } - -/** Collection date condition for one per-subject filter (exact date +/- tolerance days, or a range). */ -function subjectDateCondition(dateFilter: SubjectDateFilter, toleranceDays: number) { - if ('exact' in dateFilter) { - const from = shiftCalendarDate(dateFilter.exact, -toleranceDays) - const to = shiftCalendarDate(dateFilter.exact, toleranceDays) - if (!from || !to) return eq(specimen.collectionDate, dateFilter.exact) - return and( - gte(specimen.collectionDate, from), - lte(specimen.collectionDate, to), - ) - } - const bounds = [ - ...(dateFilter.from ? [gte(specimen.collectionDate, dateFilter.from)] : []), - ...(dateFilter.to ? [lte(specimen.collectionDate, dateFilter.to)] : []), - ] - return bounds.length > 0 ? and(...bounds) : null -} From edbf02341e914942925796fadc3b86880adb6fde Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:08:05 -0700 Subject: [PATCH 80/85] docs: point export and statistics code at Container selection. Every export and statistics path now selects through src/lib/container-selection/, so the rule lists no unmigrated paths. Co-Authored-By: Claude Opus 5.5 --- CODING_STANDARDS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 1276613..ef8a487 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -31,6 +31,7 @@ Paths are relative to `packages/api/`. - Return the full `SpecimenSource` shape to every caller, not a per-endpoint subset (ADR 0007). - Load a container read payload (placement, Tags, Specimen, Source) with `loadContainerReadViews` or `loadContainerReadViewsByIds` from `src/lib/container-read-view.ts` (ADR 0009). - Project the loaded view to its read path's type: `EnrichedContainerApi` for list, derivations and PATCH, `EnrichedStorageContainer` for collection detail, and CSV row fields for Container export (ADR 0009). +- To choose which Containers an export or statistic covers, call `selectContainers` from `src/lib/container-selection/`, and filter a paginated Container query by Tags with `containerHasAllTags` (ADR 0011). ### Routes From 46deaf8172a14dd9a0dc33ca9047420d13b6e0b7 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:18:00 -0700 Subject: [PATCH 81/85] refactor(api): let Container selection own its filter rules. Container selection now owns the rules its callers re-derived: - containerTypes is typed as ContainerSubtype. containerTypesFilter narrows wire values and drops unrecognised ones, so a list of only unrecognised values still matches nothing; 'unknown' matches Containers with no subtype row, as ADR 0011 now says. - hasContainerLevelFilter replaces dashboard-stats' own rule, which left out Container created. - SpecimenSourceType and specimenSourceType replace the ternary in the database selection and the check in statisticsSelection. Export and statistics look up a Study through identifier-resolution (resolveStudyRecordByShortCode backs resolveStudyByShortCode) instead of their own queries. Export inverts subject name maps with one helper, and multi-study JSON metadata uses MULTI_STUDY_RECORD, as barcode export does. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0011-container-selection.md | 2 +- .../__tests__/container-selection.test.ts | 8 ++--- .../src/lib/container-selection/conditions.ts | 28 ++++++++++++++--- .../src/lib/container-selection/filters.ts | 25 +++++++++++++++ .../api/src/lib/container-selection/index.ts | 17 ++++------ .../api/src/lib/container-selection/types.ts | 13 ++++++-- .../api/src/lib/export/container-export.ts | 29 ++++++++--------- packages/api/src/lib/identifier-resolution.ts | 16 +++++----- .../api/src/lib/statistics/dashboard-stats.ts | 31 +++++++++---------- 9 files changed, 109 insertions(+), 60 deletions(-) create mode 100644 packages/api/src/lib/container-selection/filters.ts diff --git a/docs/adr/0011-container-selection.md b/docs/adr/0011-container-selection.md index 364f376..b081b5d 100644 --- a/docs/adr/0011-container-selection.md +++ b/docs/adr/0011-container-selection.md @@ -32,7 +32,7 @@ We put **Container selection** (see [CONTEXT.md](../../CONTEXT.md)) behind one m - **Tags**: a Container matches when it carries every selected Tag. This holds for every caller, `GET /containers` included. - **Created**: Specimen created and Container created are separate filters. The routes keep their wire names: export's `created_from`/`created_to` map to Container created, and statistics' to Specimen created. -- **Container type**: applied once, here. The GET export's "No containers found" 404 now fires on the filtered result, so a Study whose Containers are all of other types returns 404 instead of an empty file. +- **Container type**: applied once, here. The GET export's "No containers found" 404 now fires on the filtered result, so a Study whose Containers are all of other types returns 404 instead of an empty file. The type `unknown` matches Containers with no subtype row; any other unrecognised type matches nothing. - **Location**: the Location and every Location under it. - **Collection dates**: a listed Subject with its own dates ignores the global collection-date range. Undated Subjects get the global range. Tolerance widens exact dates only. - **Specimens**: with no Container-level filter (container type, Tag, Location, Container created), every matching Specimen is included, with or without Containers. With one, only Specimens that have a matching Container are included. Export ignores Specimens with no Container. diff --git a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts index 05d3353..5fa49e4 100644 --- a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts +++ b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts @@ -31,7 +31,7 @@ import { storageContainer as storageContainerTable, storageContainerTag, } from '../../../db/schema' -import { containerHasAllTags, selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '..' +import { containerHasAllTags, containerTypesFilter, selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '..' describe('selectContainers', () => { let db: Database @@ -236,7 +236,7 @@ describe('selectContainers', () => { const untypedOnly = await seedSpecimen({ studySubjectId: subject.id }) const untyped = await seedContainer(untypedOnly.id, { kind: 'untyped' }) - const vials = await selectContainers(db, select(study.id, subject.id, { containerTypes: ['cryovial_tube', 'not_a_type'] })) + const vials = await selectContainers(db, select(study.id, subject.id, { containerTypes: containerTypesFilter(['cryovial_tube', 'not_a_type']) })) const unknown = await selectContainers(db, select(study.id, subject.id, { containerTypes: ['unknown'] })) expect(ids(vials.containers)).toEqual([vial.id]) @@ -246,12 +246,12 @@ describe('selectContainers', () => { expect(ids(unknown.specimens)).toEqual([untypedOnly.id]) }) - it('matches nothing when every listed container type is unknown', async () => { + it('matches nothing when every listed container type is unrecognised', async () => { const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) const subject = await seedSubject(study.id, 'P1') await seedContainer((await seedSpecimen({ studySubjectId: subject.id })).id) - const result = await selectContainers(db, select(study.id, subject.id, { containerTypes: ['not_a_type'] })) + const result = await selectContainers(db, select(study.id, subject.id, { containerTypes: containerTypesFilter(['not_a_type']) })) expect(result).toEqual({ containers: [], specimens: [], containerTypes: new Map() }) }) diff --git a/packages/api/src/lib/container-selection/conditions.ts b/packages/api/src/lib/container-selection/conditions.ts index 7af07de..be81a33 100644 --- a/packages/api/src/lib/container-selection/conditions.ts +++ b/packages/api/src/lib/container-selection/conditions.ts @@ -1,4 +1,4 @@ -import { count, gte, inArray, sql, type SQL } from 'drizzle-orm' +import { count, gte, inArray, isNotNull, sql, type SQL } from 'drizzle-orm' import type { SQLiteColumn } from 'drizzle-orm/sqlite-core' import { bag, @@ -17,7 +17,7 @@ import { } from '../../db/schema' import type { ContainerSubtype } from '../container-placement' import { dateToUpperBound } from '../sql-helpers' -import type { ContainerSelectionFilters, DateRange } from './types' +import type { ContainerSelectionFilters, DateRange, SpecimenSourceType } from './types' /** * A Container's type, from the subtype table holding it, in the precedence @@ -39,6 +39,16 @@ export function dateRangeConditions(column: SQLiteColumn, range: DateRange | und ] } +/** Specimens of the given source type; undefined, every Specimen. */ +export function sourceTypeCondition(sourceType: SpecimenSourceType | undefined): SQL | undefined { + if (sourceType === undefined) return undefined + const sources: Record = { + subject: isNotNull(specimen.studySubjectId), + control: isNotNull(specimen.controlBatchId), + } + return sources[sourceType] +} + /** * Conditions on the Specimen row other than its collection date, which depends on the * selection: specimen types and Specimen created. @@ -58,8 +68,10 @@ export function specimenFilterConditions(filters: ContainerSelectionFilters): SQ */ export function containerFilterConditions(filters: ContainerSelectionFilters): SQL[] { const conditions = [...dateRangeConditions(storageContainer.created, filters.containerCreated)] - if (filters.containerTypes && filters.containerTypes.length > 0) { - conditions.push(inArray(containerTypeSql, [...new Set(filters.containerTypes)])) + if (filters.containerTypes) { + conditions.push( + filters.containerTypes.length > 0 ? inArray(containerTypeSql, [...new Set(filters.containerTypes)]) : sql`0`, + ) } const tags = containerHasAllTags(filters.tagIds ?? []) if (tags) conditions.push(tags) @@ -67,6 +79,14 @@ export function containerFilterConditions(filters: ContainerSelectionFilters): S return conditions } +/** + * Whether any Container-level filter is set: Container created, container types, Tags or + * Location. With one set, the selection keeps only Specimens with a matching Container. + */ +export function hasContainerLevelFilter(filters: ContainerSelectionFilters): boolean { + return containerFilterConditions(filters).length > 0 +} + /** * Containers stored in a Location or any Location under it: tubes and static wells on * micronix plates, tubes in cryovial boxes, and papers on sheets in boxes or bags there. diff --git a/packages/api/src/lib/container-selection/filters.ts b/packages/api/src/lib/container-selection/filters.ts new file mode 100644 index 0000000..4290279 --- /dev/null +++ b/packages/api/src/lib/container-selection/filters.ts @@ -0,0 +1,25 @@ +import type { ContainerSubtype } from '../container-placement' +import type { SpecimenSourceType } from './types' + +/** Every Container type, in display order. `'unknown'` is a Container with no subtype row. */ +export const CONTAINER_TYPES = [ + 'micronix_tube', + 'cryovial_tube', + 'paper', + 'static_well', + 'unknown', +] as const satisfies readonly ContainerSubtype[] + +/** + * Narrow wire container types to the Container types filter. No values means every type; + * unrecognised values are dropped, so a list of only unrecognised values matches nothing. + */ +export function containerTypesFilter(values: readonly string[] | undefined): ContainerSubtype[] | undefined { + if (!values || values.length === 0) return undefined + return CONTAINER_TYPES.filter((type) => values.includes(type)) +} + +/** Narrow a wire source type; anything other than `'subject'` or `'control'` means both. */ +export function specimenSourceType(value: string | undefined): SpecimenSourceType | undefined { + return value === 'subject' || value === 'control' ? value : undefined +} diff --git a/packages/api/src/lib/container-selection/index.ts b/packages/api/src/lib/container-selection/index.ts index 4107540..eb6b35a 100644 --- a/packages/api/src/lib/container-selection/index.ts +++ b/packages/api/src/lib/container-selection/index.ts @@ -3,7 +3,7 @@ * selection and its filters match. Every filter is defined once, here. The module selects * only; callers enrich with `loadContainerReadViews` (ADR 0009). */ -import { and, eq, getTableColumns, inArray, isNotNull, type SQL } from 'drizzle-orm' +import { and, eq, getTableColumns, inArray, type SQL } from 'drizzle-orm' import type { Database } from '../../db/client' import { specimen, storageContainer, studySubject } from '../../db/schema' import type { ContainerSubtype } from '../container-placement' @@ -11,13 +11,15 @@ import { containerFilterConditions, containerTypeSql, dateRangeConditions, + sourceTypeCondition, specimenFilterConditions, } from './conditions' import { micronixBarcodeScopes, subjectListScopes, type SelectionScope } from './scopes' import type { ContainerSelection, ContainerSelectionResult, SelectedContainer, SelectedSpecimen } from './types' export * from './types' -export { containerHasAllTags } from './conditions' +export { containerHasAllTags, hasContainerLevelFilter } from './conditions' +export { CONTAINER_TYPES, containerTypesFilter, specimenSourceType } from './filters' /** Select the Containers and Specimens a selection matches, with each Container's type. */ export async function selectContainers( @@ -46,16 +48,9 @@ export async function selectContainers( dateTolerance: selection.dateTolerance ?? 0, }) break - case 'database': { - const source = - selection.sourceType === 'subject' - ? isNotNull(specimen.studySubjectId) - : selection.sourceType === 'control' - ? isNotNull(specimen.controlBatchId) - : undefined - scopes = [{ specimens: and(source, ...collectionDate) }] + case 'database': + scopes = [{ specimens: and(sourceTypeCondition(selection.sourceType), ...collectionDate) }] break - } } const specimenConditions = specimenFilterConditions(filters) return runScopes( diff --git a/packages/api/src/lib/container-selection/types.ts b/packages/api/src/lib/container-selection/types.ts index 398be34..30fd9ef 100644 --- a/packages/api/src/lib/container-selection/types.ts +++ b/packages/api/src/lib/container-selection/types.ts @@ -18,8 +18,12 @@ export interface ContainerSelectionFilters { specimenCreated?: DateRange /** When the Container was created. A Container-level filter. */ containerCreated?: DateRange - /** Container types (`ContainerSubtype` values); unknown values match nothing. A Container-level filter. */ - containerTypes?: readonly string[] + /** + * Container types. Omitted means every type; an empty list matches nothing. `'unknown'` + * matches Containers with no subtype row. Narrow wire values with `containerTypesFilter`, + * which drops unrecognised ones. A Container-level filter. + */ + containerTypes?: readonly ContainerSubtype[] /** Containers carrying every one of these Tags. A Container-level filter. */ tagIds?: readonly number[] /** Containers stored in this Location or any Location under it. A Container-level filter. */ @@ -38,6 +42,9 @@ export interface SubjectSelectionEntry { dates?: readonly SubjectDateFilter[] } +/** Where a Specimen comes from: a Subject or a Control batch. */ +export type SpecimenSourceType = 'subject' | 'control' + /** What to select from (ADR 0011). Every id is resolved by the caller; an unknown id matches nothing. */ export type ContainerSelection = | { kind: 'study'; studyId: number; filters?: ContainerSelectionFilters } @@ -52,7 +59,7 @@ export type ContainerSelection = | { kind: 'database' /** Specimens of Subjects or of Control batches; omitted means both. */ - sourceType?: 'subject' | 'control' + sourceType?: SpecimenSourceType filters?: ContainerSelectionFilters } diff --git a/packages/api/src/lib/export/container-export.ts b/packages/api/src/lib/export/container-export.ts index c874e0c..f0adf1c 100644 --- a/packages/api/src/lib/export/container-export.ts +++ b/packages/api/src/lib/export/container-export.ts @@ -1,8 +1,7 @@ import { subjectDateSelections, type ContainerExportData, type SubjectDateFilter } from '@sampledb/contract' -import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' -import { study } from '../../db/schema' import { + containerTypesFilter, selectContainers, type ContainerSelection, type ContainerSelectionFilters, @@ -13,7 +12,11 @@ import { enrichContainerData } from './enrich' import { formatAsCSV, formatAsExcel, formatAsJSON } from './format' import { resolveMicronixBarcodesToContainers } from './query' import { buildExportSummary, validateStudyCodes } from './validate' -import { resolveSubjectNamesByStudy, resolveSubjectsByStudyGrouped } from '../identifier-resolution' +import { + resolveStudyRecordByShortCode, + resolveSubjectNamesByStudy, + resolveSubjectsByStudyGrouped, +} from '../identifier-resolution' import type { CSVExportOptions, ExportFilters, @@ -99,13 +102,14 @@ function exportSelectionFilters(filters: Omit): Containe specimenTypeIds: filters.specimen_type_ids, collectionDate: { from: filters.date_from, to: filters.date_to }, containerCreated: { from: filters.created_from, to: filters.created_to }, - containerTypes: filters.container_types, + containerTypes: containerTypesFilter(filters.container_types), tagIds: filters.tag_ids, } } -async function findStudyByShortCode(database: Database, shortCode: string): Promise { - return database.select().from(study).where(eq(study.shortCode, shortCode)).get() +/** Subject names by Subject id, inverting a name to id map. */ +function subjectNamesById(subjectIdsByName: ReadonlyMap): Map { + return new Map([...subjectIdsByName].map(([name, id]) => [id, name])) } async function formatContainerExport( @@ -149,7 +153,7 @@ export async function runSingleStudyContainerExportGet( | { kind: 'json'; json: Awaited>; filename: string } > { const { filters, format, columns, csvOptions, userId, countOnly } = params - const studyRecord = await findStudyByShortCode(database, filters.study) + const studyRecord = await resolveStudyRecordByShortCode(database, filters.study) if (!studyRecord) { throw new ExpectedNotFoundError(`Study with short code '${filters.study}' not found`) } @@ -225,16 +229,13 @@ export async function runSingleStudyContainerExportPost( dateTolerance = 0, } = params - const studyRecord = await findStudyByShortCode(database, studyCode) + const studyRecord = await resolveStudyRecordByShortCode(database, studyCode) if (!studyRecord) { throw new ExpectedNotFoundError('Study not found') } const subjectNameToId = await resolveSubjectNamesByStudy(database, subjectNames, studyRecord.id) - const subjectIdToName = new Map() - for (const [name, id] of subjectNameToId.entries()) { - subjectIdToName.set(id, name) - } + const subjectIdToName = subjectNamesById(subjectNameToId) if (subjectNameToId.size === 0) { const summary: ExportSummary = { @@ -410,7 +411,7 @@ export async function runMultiStudyContainerExportPost( for (const [studyId, studyEntries] of entriesByStudy) { const studyRecord = validation.studies.get(studyId)! const subjectNameToId = subjectsByStudy.get(studyId) ?? new Map() - const subjectIdToName = new Map([...subjectNameToId].map(([name, id]) => [id, name])) + const subjectIdToName = subjectNamesById(subjectNameToId) const studyData = enrichedData.filter((row) => row.study_id === studyId) const studySummary = await buildExportSummary( studyData, @@ -443,7 +444,7 @@ export async function runMultiStudyContainerExportPost( enrichedData, format, { study: 'MULTI' }, - validation.studies.values().next().value ?? MULTI_STUDY_RECORD, + MULTI_STUDY_RECORD, columns, csvOptions, userId diff --git a/packages/api/src/lib/identifier-resolution.ts b/packages/api/src/lib/identifier-resolution.ts index 4deff87..b049c11 100644 --- a/packages/api/src/lib/identifier-resolution.ts +++ b/packages/api/src/lib/identifier-resolution.ts @@ -7,13 +7,15 @@ import type { DatabaseOrTransaction } from './db-types' * Resolve study short code to study ID */ export async function resolveStudyByShortCode(database: Database, shortCode: string): Promise { - const studyRecord = await database - .select({ id: study.id }) - .from(study) - .where(eq(study.shortCode, shortCode)) - .get() - - return studyRecord?.id ?? null + return (await resolveStudyRecordByShortCode(database, shortCode))?.id ?? null +} + +/** Resolve a study short code to the Study record, or undefined when no Study has it. */ +export async function resolveStudyRecordByShortCode( + database: Database, + shortCode: string, +): Promise { + return database.select().from(study).where(eq(study.shortCode, shortCode)).get() } /** diff --git a/packages/api/src/lib/statistics/dashboard-stats.ts b/packages/api/src/lib/statistics/dashboard-stats.ts index 2db5e84..2fa83f5 100644 --- a/packages/api/src/lib/statistics/dashboard-stats.ts +++ b/packages/api/src/lib/statistics/dashboard-stats.ts @@ -1,7 +1,13 @@ -import { eq } from 'drizzle-orm' import type { Database } from '../../db/client' -import { study } from '../../db/schema' -import { selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '../container-selection' +import { + containerTypesFilter, + hasContainerLevelFilter, + selectContainers, + specimenSourceType, + type ContainerSelection, + type ContainerSelectionFilters, +} from '../container-selection' +import { resolveStudyByShortCode } from '../identifier-resolution' import { computeContainerAggregates } from './container-aggregates' import { computeSpecimenAggregates } from './specimen-aggregates' import { computeStorageStatistics } from './storage-aggregates' @@ -46,7 +52,7 @@ function selectionFilters(filters: StatisticsFilters): ContainerSelectionFilters specimenTypeIds: specimenTypeId === undefined ? undefined : [specimenTypeId], collectionDate: { from: filters.collection_date_from, to: filters.collection_date_to }, specimenCreated: { from: filters.created_from, to: filters.created_to }, - containerTypes: filters.container_type ? [filters.container_type] : undefined, + containerTypes: containerTypesFilter(filters.container_type ? [filters.container_type] : undefined), tagIds: filters.tag_ids, locationId: parseId(filters.location_id), } @@ -61,17 +67,12 @@ async function statisticsSelection( database: Database, filters: StatisticsFilters, ): Promise { - const sourceType = - filters.source_type === 'subject' || filters.source_type === 'control' ? filters.source_type : undefined + const sourceType = specimenSourceType(filters.source_type) if (!filters.study) return { kind: 'database', sourceType, filters: selectionFilters(filters) } if (sourceType === 'control') return null - const studyRecord = await database - .select({ id: study.id }) - .from(study) - .where(eq(study.shortCode, filters.study)) - .get() - if (!studyRecord) return null - return { kind: 'study', studyId: studyRecord.id, filters: selectionFilters(filters) } + const studyId = await resolveStudyByShortCode(database, filters.study) + if (studyId === null) return null + return { kind: 'study', studyId, filters: selectionFilters(filters) } } /** Filtered dashboard statistics for specimens, containers, and storage. */ @@ -84,10 +85,8 @@ export async function getDashboardStatistics( const { containers, specimens, containerTypes } = await selectContainers(database, selection) if (specimens.length === 0) return emptyDashboardStatistics() - const hasContainerFilter = - !!filters.container_type || (filters.tag_ids?.length ?? 0) > 0 || parseId(filters.location_id) !== undefined const specimenAggregates = await computeSpecimenAggregates(database, specimens, { - useSpecimenTypeCache: !hasContainerFilter, + useSpecimenTypeCache: !hasContainerLevelFilter(selectionFilters(filters)), }) const containerTotal = containers.length const averagePerSpecimen = specimenAggregates.total > 0 ? containerTotal / specimenAggregates.total : 0 From d0c2b51f60767d16b1b63f8a5a95276f60e877d7 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:18:05 -0700 Subject: [PATCH 82/85] fix(export): select available types through Container selection. GET /export/available-types picked a Study's Specimens and Containers with unchunked IN lists that grew with the Study, and failed once its Subjects outnumbered SQLite's bound-parameter limit. It now calls selectContainers with a study selection and reads the specimen types and containerTypes from the result. The response is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../api/src/routes/__tests__/export.test.ts | 64 ++++++++++++++- packages/api/src/routes/export.ts | 78 ++++--------------- 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/packages/api/src/routes/__tests__/export.test.ts b/packages/api/src/routes/__tests__/export.test.ts index d94ed31..593e3a6 100644 --- a/packages/api/src/routes/__tests__/export.test.ts +++ b/packages/api/src/routes/__tests__/export.test.ts @@ -6,7 +6,8 @@ import { type AuthenticatedRouteTestContext, } from '../../__tests__/helpers/authenticated-route-test' import { createExportRoutes } from '../export' -import { errorLogs, specimen as specimenTable } from '../../db/schema' +import { errorLogs, specimen as specimenTable, study as studyTable, unit as unitTable } from '../../db/schema' +import { SQL_IN_CHUNK_SIZE } from '../../lib/sql-helpers' import * as specimensCsv from '../../lib/export/specimens-csv' import { createTestStudy, @@ -458,6 +459,67 @@ describe('Export API', () => { }) }) + describe('GET /api/export/available-types', () => { + it('returns 400 when study parameter is missing', async () => { + const res = await ctx.request('/api/export/available-types', { method: 'GET' }) + expect(res.status).toBe(400) + }) + + it('returns 404 when study does not exist', async () => { + const res = await ctx.request('/api/export/available-types?study=NOPE', { method: 'GET' }) + expect(res.status).toBe(404) + expect(await res.json()).toMatchObject({ error: 'Study not found' }) + }) + + it("lists the Study's specimen types and container types", async () => { + await seedTypedContainers('TYPES', { cryovial: true }) + const other = await createTestStudy(ctx.db, { title: 'Other', shortCode: 'OTHER' }) + const otherSubject = await createTestStudySubject(ctx.db, { studyId: other.id, name: 'Subj1' }) + const plasma = await createTestSpecimenType(ctx.db, { name: 'Plasma' }) + const [unit] = await ctx.db.select().from(unitTable) + await createTestStorageContainer(ctx.db, { + specimenId: (await createTestSpecimen(ctx.db, plasma.id, { studySubjectId: otherSubject.id })).id, + unitId: unit.id, + }) + + const res = await ctx.request('/api/export/available-types?study=TYPES', { method: 'GET' }) + + expect(res.status).toBe(200) + const data = (await res.json()) as { specimen_types: Array<{ id: number; name: string }>; container_types: string[] } + expect(data.specimen_types.map((type) => type.name)).toEqual(['Blood']) + expect(data.container_types).toEqual(['micronix_tube', 'cryovial_tube']) + }) + + it('returns empty lists for a Study with Specimens but no Containers', async () => { + const study = await createTestStudy(ctx.db, { title: 'Bare', shortCode: 'BARE' }) + const subject = await createTestStudySubject(ctx.db, { studyId: study.id, name: 'Subj1' }) + const specimenType = await createTestSpecimenType(ctx.db, { name: 'Plasma' }) + await createTestSpecimen(ctx.db, specimenType.id, { studySubjectId: subject.id }) + + const res = await ctx.request('/api/export/available-types?study=BARE', { method: 'GET' }) + + expect(await res.json()).toEqual({ specimen_types: [{ id: specimenType.id, name: 'Plasma' }], container_types: [] }) + }) + + it('lists the types of a Study with more Subjects than SQLite allows as bound parameters', async () => { + await seedTypedContainers('BIG', { cryovial: false }) + const [big] = await ctx.db.select().from(studyTable).where(eq(studyTable.shortCode, 'BIG')) + ctx.sqlite.exec('BEGIN') + const addSubject = ctx.sqlite.prepare( + "INSERT INTO study_subject (study_id, name, created, last_updated) VALUES (?, ?, '2024-01-01', '2024-01-01')", + ) + for (let i = 0; i < SQL_IN_CHUNK_SIZE * 140; i++) addSubject.run(big.id, `S${i}`) + ctx.sqlite.exec('COMMIT') + + const res = await ctx.request('/api/export/available-types?study=BIG', { method: 'GET' }) + + expect(res.status).toBe(200) + const data = (await res.json()) as { specimen_types: Array<{ name: string }>; container_types: string[] } + expect(data.specimen_types.map((type) => type.name)).toEqual(['Blood']) + expect(data.container_types).toEqual(['micronix_tube']) + }, 60_000) + }) + describe('error persistence', () => { it('persists export 500 failures to error_logs with stable client body', async () => { const spy = vi diff --git a/packages/api/src/routes/export.ts b/packages/api/src/routes/export.ts index 54fc36d..ad9ad8e 100644 --- a/packages/api/src/routes/export.ts +++ b/packages/api/src/routes/export.ts @@ -6,18 +6,10 @@ import { ExpectedNotFoundError, RouteError, } from '../lib/error-handler' -import { - specimen, - studySubject, - study, - specimenType, - storageContainer, - micronixTube, - cryovialTube, - paper, - staticWell, -} from '../db/schema' -import { eq, inArray } from 'drizzle-orm' +import { specimenType } from '../db/schema' +import { inArray } from 'drizzle-orm' +import { CONTAINER_TYPES, selectContainers } from '../lib/container-selection' +import { resolveStudyByShortCode } from '../lib/identifier-resolution' import { formatExportTimestamp, parseCSVExportOptions, @@ -246,70 +238,28 @@ export function createExportRoutes(database: Database): Hono { return c.json({ error: 'Study parameter is required' }, 400) } - const studyRecord = await database.select().from(study).where(eq(study.shortCode, studyCode)).get() - if (!studyRecord) { + const studyId = await resolveStudyByShortCode(database, studyCode) + if (studyId === null) { throw new ExpectedNotFoundError('Study not found') } - const subjects = await database - .select({ id: studySubject.id }) - .from(studySubject) - .where(eq(studySubject.studyId, studyRecord.id)) - - const subjectIds = subjects.map((s) => s.id) - if (subjectIds.length === 0) { - return c.json({ specimen_types: [], container_types: [] }) - } - - let specimensQuery = database - .select({ id: specimen.id, specimenTypeId: specimen.specimenTypeId }) - .from(specimen) - - specimensQuery = - subjectIds.length === 1 - ? (specimensQuery.where(eq(specimen.studySubjectId, subjectIds[0])) as typeof specimensQuery) - : (specimensQuery.where(inArray(specimen.studySubjectId, subjectIds)) as typeof specimensQuery) - - const specimens = await specimensQuery - const specimenIds = specimens.map((s) => s.id) - if (specimenIds.length === 0) { + const selected = await selectContainers(database, { kind: 'study', studyId }) + if (selected.specimens.length === 0) { return c.json({ specimen_types: [], container_types: [] }) } - const specimenTypeIds = [...new Set(specimens.map((s) => s.specimenTypeId))] + const specimenTypeIds = [...new Set(selected.specimens.map((row) => row.specimenTypeId))] const specimenTypes = await database - .select() + .select({ id: specimenType.id, name: specimenType.name }) .from(specimenType) .where(inArray(specimenType.id, specimenTypeIds)) + .orderBy(specimenType.id) - const containers = await database - .select({ id: storageContainer.id }) - .from(storageContainer) - .where(inArray(storageContainer.specimenId, specimenIds)) - - const containerIds = containers.map((row) => row.id) - if (containerIds.length === 0) { - return c.json({ - specimen_types: specimenTypes.map((st) => ({ id: st.id, name: st.name })), - container_types: [], - }) - } - - const [micronixTubes, cryovialTubes, papers, staticWells] = await Promise.all([ - database.select({ id: micronixTube.id }).from(micronixTube).where(inArray(micronixTube.id, containerIds)), - database.select({ id: cryovialTube.id }).from(cryovialTube).where(inArray(cryovialTube.id, containerIds)), - database.select({ id: paper.id }).from(paper).where(inArray(paper.id, containerIds)), - database.select({ id: staticWell.id }).from(staticWell).where(inArray(staticWell.id, containerIds)), - ]) - - const containerTypes: string[] = [] - if (micronixTubes.length > 0) containerTypes.push('micronix_tube') - if (cryovialTubes.length > 0) containerTypes.push('cryovial_tube') - if (papers.length > 0) containerTypes.push('paper') - if (staticWells.length > 0) containerTypes.push('static_well') + const present = new Set(selected.containerTypes.values()) + const containerTypes = CONTAINER_TYPES.filter((type) => type !== 'unknown' && present.has(type)) return c.json({ - specimen_types: specimenTypes.map((st) => ({ id: st.id, name: st.name })), + specimen_types: specimenTypes, container_types: containerTypes, }) } catch (error) { From 31b046a0f5ad886dd941ee1acd410f8a439b68ae Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:18:50 -0700 Subject: [PATCH 83/85] test(api): seed Container selection tests through factories. createTestStorageContainer takes a created override, createTestSpecimen takes collectionDate and created, and createTestContainerTags links Tags to a Container. The Container selection tests use them instead of raw inserts and updates. Co-Authored-By: Claude Opus 5.5 --- .../api/src/__tests__/helpers/factories.ts | 19 +++++-- .../__tests__/container-selection.test.ts | 52 ++++++------------- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/packages/api/src/__tests__/helpers/factories.ts b/packages/api/src/__tests__/helpers/factories.ts index e5d7feb..78f7c8f 100644 --- a/packages/api/src/__tests__/helpers/factories.ts +++ b/packages/api/src/__tests__/helpers/factories.ts @@ -21,6 +21,7 @@ import { cryovialTube, staticWell, paper, + storageContainerTag, } from '../../db/schema' import { utcNow } from '../../lib/datetime' @@ -103,7 +104,7 @@ export async function createTestUnit(db: Database, data: { */ export async function createTestStorageContainer( db: Database, - options?: { specimenId?: number; unitId?: number } + options?: { specimenId?: number; unitId?: number; created?: string } ) { // Create unit if not provided let unitId = options?.unitId @@ -129,10 +130,21 @@ export async function createTestStorageContainer( unitId: unitId as number, totalQuantity: 1.0, remainingQuantity: 1.0, + ...(options?.created ? { created: options.created } : {}), }).returning() return result } +/** + * Link Tags to a storage container + */ +export async function createTestContainerTags(db: Database, storageContainerId: number, tagIds: readonly number[]) { + return db + .insert(storageContainerTag) + .values(tagIds.map((tagId) => ({ storageContainerId, tagId }))) + .returning() +} + /** * Create a test micronix plate (for collections route tests) */ @@ -348,7 +360,7 @@ export async function createTestStudySubject(db: Database, data: { studyId: numb export async function createTestSpecimen( db: Database, specimenTypeId: number, - options: { studySubjectId?: number; controlBatchId?: number } = {} + options: { studySubjectId?: number; controlBatchId?: number; collectionDate?: string; created?: string } = {} ) { const now = utcNow() let { studySubjectId, controlBatchId } = options @@ -370,7 +382,8 @@ export async function createTestSpecimen( specimenTypeId, studySubjectId, controlBatchId, - created: now, + collectionDate: options.collectionDate, + created: options.created ?? now, lastUpdated: now, }).returning() return result diff --git a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts index 5fa49e4..ac46b29 100644 --- a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts +++ b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { count, eq } from 'drizzle-orm' +import { count } from 'drizzle-orm' import type { Database as SQLiteDatabase } from 'bun:sqlite' import { setupTestDatabase, cleanupTestDatabase } from '../../../__tests__/helpers/db-setup' import { SQL_IN_CHUNK_SIZE } from '../../sql-helpers' @@ -7,6 +7,7 @@ import { createTestControlBatch, createTestControlDefinition, createTestCryovialBox, + createTestContainerTags, createTestCryovialTube, createTestLocation, createTestMicronixPlate, @@ -26,11 +27,7 @@ import { createTestUnit, } from '../../../__tests__/helpers/factories' import type { Database } from '../../../db/client' -import { - specimen as specimenTable, - storageContainer as storageContainerTable, - storageContainerTag, -} from '../../../db/schema' +import { storageContainer as storageContainerTable } from '../../../db/schema' import { containerHasAllTags, containerTypesFilter, selectContainers, type ContainerSelection, type ContainerSelectionFilters } from '..' describe('selectContainers', () => { @@ -65,15 +62,11 @@ describe('selectContainers', () => { source: { studySubjectId: number } | { controlBatchId: number }, fields: { collectionDate?: string; created?: string; specimenTypeId?: number } = {}, ) { - const row = await createTestSpecimen(db, fields.specimenTypeId ?? specimenTypeId, source) - await db - .update(specimenTable) - .set({ - collectionDate: fields.collectionDate ?? null, - ...(fields.created ? { created: fields.created } : {}), - }) - .where(eq(specimenTable.id, row.id)) - return row + return createTestSpecimen(db, fields.specimenTypeId ?? specimenTypeId, { + ...source, + collectionDate: fields.collectionDate, + created: fields.created, + }) } type ContainerKind = 'micronix_tube' | 'cryovial_tube' | 'untyped' @@ -82,13 +75,7 @@ describe('selectContainers', () => { specimenId: number, options: { kind?: ContainerKind; created?: string; collectionId?: number; barcode?: string } = {}, ) { - const container = await createTestStorageContainer(db, { specimenId, unitId }) - if (options.created) { - await db - .update(storageContainerTable) - .set({ created: options.created }) - .where(eq(storageContainerTable.id, container.id)) - } + const container = await createTestStorageContainer(db, { specimenId, unitId, created: options.created }) const kind = options.kind ?? 'micronix_tube' if (kind === 'micronix_tube') { barcodeCount += 1 @@ -103,10 +90,6 @@ describe('selectContainers', () => { return container } - async function tag(containerId: number, ...tagIds: number[]) { - await db.insert(storageContainerTag).values(tagIds.map((tagId) => ({ storageContainerId: containerId, tagId }))) - } - const ids = (rows: Array<{ id: number }>) => rows.map((row) => row.id) describe('study selection', () => { @@ -263,9 +246,9 @@ describe('selectContainers', () => { const blue = (await createTestTag(db, { name: 'blue' })).id const both = await seedSpecimen({ studySubjectId: subject.id }) const bothTube = await seedContainer(both.id) - await tag(bothTube.id, red, blue) + await createTestContainerTags(db, bothTube.id, [red, blue]) const redOnly = await seedSpecimen({ studySubjectId: subject.id }) - await tag((await seedContainer(redOnly.id)).id, red) + await createTestContainerTags(db, (await seedContainer(redOnly.id)).id, [red]) const result = await selectContainers(db, select(study.id, subject.id, { tagIds: [red, blue] })) @@ -278,7 +261,7 @@ describe('selectContainers', () => { const red = (await createTestTag(db, { name: 'red' })).id const subject = await seedSubject(study.id, 'P1') const specimen = await seedSpecimen({ studySubjectId: subject.id }) - await tag((await seedContainer(specimen.id)).id, red) + await createTestContainerTags(db, (await seedContainer(specimen.id)).id, [red]) const result = await selectContainers(db, select(study.id, subject.id, { tagIds: [red, red + 999] })) @@ -537,14 +520,9 @@ describe('containerHasAllTags', () => { const specimenId = (await createTestSpecimen(db, specimenTypeId)).id const containers = [] for (let i = 0; i < 4; i++) containers.push(await createTestStorageContainer(db, { specimenId, unitId })) - const links = [ - { storageContainerId: containers[0].id, tagId: red }, - { storageContainerId: containers[0].id, tagId: blue }, - { storageContainerId: containers[1].id, tagId: red }, - { storageContainerId: containers[2].id, tagId: red }, - { storageContainerId: containers[2].id, tagId: blue }, - ] - await db.insert(storageContainerTag).values(links) + await createTestContainerTags(db, containers[0].id, [red, blue]) + await createTestContainerTags(db, containers[1].id, [red]) + await createTestContainerTags(db, containers[2].id, [red, blue]) const where = containerHasAllTags([red, blue, red]) const page = await db From 974d85d424cc7310e6eecd3ac72667ece5b05405 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:21:08 -0700 Subject: [PATCH 84/85] fix(api): bound the parameters of each subject-list query. subjectListScopes chunked by Subject, but each dated Subject added a condition per date to one OR, so 500 Subjects with 80 dates each bound 80,500 parameters and the query failed. Each listed date is now its own condition, and a query takes at most SQL_IN_CHUNK_SIZE of them; undated Subjects get their own query. The list is named subjectDateConditions, since it no longer mixes in the undated condition. Scopes can now overlap, so selectContainers collects Containers by id. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/container-selection.test.ts | 26 +++++++++++++++++++ .../api/src/lib/container-selection/index.ts | 8 +++--- .../api/src/lib/container-selection/scopes.ts | 19 +++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts index ac46b29..109d54e 100644 --- a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts +++ b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts @@ -430,6 +430,32 @@ describe('selectContainers', () => { expect(ids(result.specimens)).toEqual([first.id, last.id]) }) + + it('selects every listed Subject when known Subjects each list many dates', async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subjects = [] + for (let i = 0; i < SQL_IN_CHUNK_SIZE; i++) subjects.push(await seedSubject(study.id, `P${i}`)) + // Days 1 to 80 of 2023, one per visit: far more date conditions than one query can bind. + const dates = Array.from({ length: 80 }, (_, day) => new Date(Date.UTC(2023, 0, day + 1)).toISOString().slice(0, 10)) + const first = await seedSpecimen({ studySubjectId: subjects[0].id }, { collectionDate: '2023-01-01' }) + const firstTube = await seedContainer(first.id) + await seedSpecimen({ studySubjectId: subjects[0].id }, { collectionDate: '2024-01-01' }) + const last = await seedSpecimen({ studySubjectId: subjects.at(-1)!.id }, { collectionDate: '2023-03-21' }) + const lastTube = await seedContainer(last.id) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: subjects.map((subject) => ({ + studyId: study.id, + subjectId: subject.id, + dates: dates.map((exact) => ({ exact })), + })), + dateTolerance: 1, + }) + + expect(ids(result.specimens)).toEqual([first.id, last.id]) + expect(ids(result.containers)).toEqual([firstTube.id, lastTube.id]) + }, 60_000) }) describe('whole-database selection', () => { diff --git a/packages/api/src/lib/container-selection/index.ts b/packages/api/src/lib/container-selection/index.ts index eb6b35a..2d641fd 100644 --- a/packages/api/src/lib/container-selection/index.ts +++ b/packages/api/src/lib/container-selection/index.ts @@ -62,7 +62,8 @@ export async function selectContainers( /** * Run each scope (one per chunk of a listed selection) and collect its Specimens and the - * Containers that also pass the Container-level conditions. With any Container-level + * Containers that also pass the Container-level conditions. Scopes may overlap, so rows are + * collected by id. With any Container-level * condition, only Specimens with a matching Container are kept. */ async function runScopes( @@ -71,7 +72,7 @@ async function runScopes( containerConditions: SQL[], ): Promise { const specimensById = new Map() - const containers: SelectedContainer[] = [] + const containersById = new Map() const containerTypes = new Map() for (const scope of scopes) { for (const row of await database.select().from(specimen).where(scope.specimens)) specimensById.set(row.id, row) @@ -81,10 +82,11 @@ async function runScopes( .innerJoin(specimen, eq(storageContainer.specimenId, specimen.id)) .where(and(scope.specimens, scope.containers, ...containerConditions)) for (const row of rows) { - containers.push(row.container) + containersById.set(row.container.id, row.container) containerTypes.set(row.container.id, row.containerType) } } + const containers = [...containersById.values()] let specimens = [...specimensById.values()] if (containerConditions.length > 0) { const stored = new Set(containers.map((container) => container.specimenId)) diff --git a/packages/api/src/lib/container-selection/scopes.ts b/packages/api/src/lib/container-selection/scopes.ts index 5e554c6..f68b5a4 100644 --- a/packages/api/src/lib/container-selection/scopes.ts +++ b/packages/api/src/lib/container-selection/scopes.ts @@ -17,6 +17,12 @@ export interface SelectionScope { containers?: SQL } +/** + * Per-Subject date conditions per query. Each binds at most three parameters, so a query + * stays under a fixed bound however many dates a Subject lists. + */ +const SUBJECT_DATE_CONDITIONS_PER_QUERY = SQL_IN_CHUNK_SIZE + /** Exactly the micronix tubes with these barcodes, and their Specimens, whatever their Source. */ export function micronixBarcodeScopes(database: Database, barcodes: readonly string[]): SelectionScope[] { const unique = [...new Set(barcodes.filter((barcode) => barcode !== ''))] @@ -61,7 +67,8 @@ export async function subjectListScopes( .from(studySubject) .where(inArray(studySubject.id, subjectIds)) const undated: number[] = [] - const dated: SQL[] = [] + // One condition per listed date of a dated Subject, so the OR grows with dates, not Subjects. + const subjectDateConditions: SQL[] = [] for (const subject of subjects) { const listed = datesBySubject.get(subject.id)?.find((item) => item.studyId === subject.studyId) if (!listed) continue @@ -72,12 +79,16 @@ export async function subjectListScopes( .map((dateFilter) => subjectDateCondition(dateFilter, options.dateTolerance)) .filter((condition) => condition !== undefined) if (dateConditions.length === 0) undated.push(subject.id) - else dated.push(and(eq(specimen.studySubjectId, subject.id), or(...dateConditions))!) + for (const condition of dateConditions) { + subjectDateConditions.push(and(eq(specimen.studySubjectId, subject.id), condition)!) + } } if (undated.length > 0) { - dated.push(and(inArray(specimen.studySubjectId, undated), ...options.collectionDate)!) + scopes.push({ specimens: and(inArray(specimen.studySubjectId, undated), ...options.collectionDate) }) + } + for (const conditions of chunkArray(subjectDateConditions, SUBJECT_DATE_CONDITIONS_PER_QUERY)) { + scopes.push({ specimens: or(...conditions) }) } - if (dated.length > 0) scopes.push({ specimens: or(...dated) }) } return scopes } From 3e79a118da6ce0898c8653d58f8998d3ff939602 Mon Sep 17 00:00:00 2001 From: Maxwell Murphy Date: Thu, 22 Oct 2026 17:21:48 -0700 Subject: [PATCH 85/85] test(api): cover Container selection filters across selection kinds. The filter table already ran each filter on study, subject-list and whole-database selections. It now also runs them on a whole-database selection narrowed to Subjects, and a subject-list test applies the other filters to a Subject's own dates. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/container-selection.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts index 109d54e..d949042 100644 --- a/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts +++ b/packages/api/src/lib/container-selection/__tests__/container-selection.test.ts @@ -144,6 +144,7 @@ describe('selectContainers', () => { ['study', (studyId, _subjectId, filters) => ({ kind: 'study', studyId, filters })], ['subject-list', (studyId, subjectId, filters) => ({ kind: 'subjects', subjects: [{ studyId, subjectId }], filters })], ['whole-database', (_studyId, _subjectId, filters) => ({ kind: 'database', filters })], + ['Subject-only whole-database', (_studyId, _subjectId, filters) => ({ kind: 'database', sourceType: 'subject', filters })], ] describe.each(selectionKinds)('filters on a %s selection', (_kind, select) => { @@ -359,6 +360,26 @@ describe('selectContainers', () => { expect(ids(result.specimens)).toEqual([visitOne.id, visitTwo.id, inRange.id]) }) + it("applies the other filters to a Subject's own dates", async () => { + const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) + const subject = await seedSubject(study.id, 'P1') + const plasmaTypeId = (await createTestSpecimenType(db, { name: 'Plasma' })).id + const blood = await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-03-01' }) + const bloodVial = await seedContainer(blood.id, { kind: 'cryovial_tube' }) + await seedContainer(blood.id, { kind: 'micronix_tube' }) + const plasma = await seedSpecimen({ studySubjectId: subject.id }, { collectionDate: '2024-03-01', specimenTypeId: plasmaTypeId }) + await seedContainer(plasma.id, { kind: 'cryovial_tube' }) + + const result = await selectContainers(db, { + kind: 'subjects', + subjects: [{ studyId: study.id, subjectId: subject.id, dates: [{ exact: '2024-03-01' }] }], + filters: { specimenTypeIds: [specimenTypeId], containerTypes: ['cryovial_tube'] }, + }) + + expect(ids(result.specimens)).toEqual([blood.id]) + expect(ids(result.containers)).toEqual([bloodVial.id]) + }) + it('widens exact per-Subject dates by the tolerance, but not date ranges', async () => { const study = await createTestStudy(db, { title: 'Study', shortCode: 'S1' }) const exact = await seedSubject(study.id, 'P1')