Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
94a2fb6
docs: record Container selection in ADR 0011 and the glossary.
m-murphy Oct 22, 2026
cb80630
feat(api): add the Container selection module from ADR 0011.
m-murphy Oct 22, 2026
b551370
feat(api): match every selected Tag in GET /containers.
m-murphy Oct 22, 2026
de3dc56
feat(export): select single-study GET export through Container select…
m-murphy Oct 22, 2026
4ce22ee
feat(web): label the dashboard's created filter Specimen created.
m-murphy Oct 22, 2026
b53074d
fix(export): return the rows from JSON POST container exports.
m-murphy Oct 22, 2026
1bf0076
feat(export): select single-study POST export through Container selec…
m-murphy Oct 22, 2026
16483fc
refactor(statistics): select dashboard statistics through Container s…
m-murphy Oct 22, 2026
ac28c65
refactor(export): select multi-study and barcode exports through Cont…
m-murphy Oct 22, 2026
8a06122
Merge branch 'feat/container-selection' into feat/container-selection-96
m-murphy Oct 22, 2026
0a95bf2
Merge branch 'feat/container-selection' into feat/container-selection-98
m-murphy Oct 22, 2026
e79d695
Merge branch 'feat/container-selection-96' into feat/container-selection
m-murphy Oct 22, 2026
bcf2cac
Merge branch 'feat/container-selection' into feat/container-selection-97
m-murphy Oct 22, 2026
57cd93a
refactor(export): take export study fields from the Source and delete…
m-murphy Oct 23, 2026
edbf023
docs: point export and statistics code at Container selection.
m-murphy Oct 23, 2026
46deaf8
refactor(api): let Container selection own its filter rules.
m-murphy Oct 23, 2026
d0c2b51
fix(export): select available types through Container selection.
m-murphy Oct 23, 2026
31b046a
test(api): seed Container selection tests through factories.
m-murphy Oct 23, 2026
974d85d
fix(api): bound the parameters of each subject-list query.
m-murphy Oct 23, 2026
3e79a11
test(api): cover Container selection filters across selection kinds.
m-murphy Oct 23, 2026
8eaa527
fix(export): treat a null subject date as undated, and reject malform…
m-murphy Oct 30, 2026
500f3d5
fix(export): resolve uploaded barcodes and subject names a chunk at a…
m-murphy Oct 30, 2026
b92b716
fix(export): report a missing study on available-types with a typed e…
m-murphy Oct 30, 2026
84b4a62
test(api): name a Container with no subtype row 'unknown', as the mod…
m-murphy Oct 30, 2026
bf8c83d
fix(export): read only distinct types for available-types.
m-murphy Oct 30, 2026
a463c69
docs: state in ADR 0011 that an undated entry wins for a Subject list…
m-murphy Oct 30, 2026
998b091
refactor(export): fold a Subject's collection dates in one place.
m-murphy Oct 30, 2026
3a66e65
refactor(export): format every Container export through one dispatch.
m-murphy Oct 30, 2026
07dbca6
refactor(api): derive ContainerSubtype from one list of Container types.
m-murphy Oct 30, 2026
6f451f6
refactor(api): report a Container-level filter on the selection result.
m-murphy Oct 30, 2026
d3dfd8a
refactor(statistics): parse filter ids with the rest of the statistic…
m-murphy Oct 30, 2026
b2b64e5
Merge branch 'docs/adr-coding-standards' into feat/container-selection
m-murphy Oct 30, 2026
6e14b17
refactor(api): derive the remaining container type unions from CONTAI…
m-murphy Oct 30, 2026
d141685
Merge branch 'docs/adr-coding-standards' into feat/container-selection
m-murphy Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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's read view (placement, identity, Tags, unit, location, Specimen, Source) with `loadContainerReadViews` or `loadContainerReadViewsByIds` from `src/lib/container-read-view.ts`, not with per-endpoint queries (ADR 0009).
- Map the read 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

Expand Down
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions docs/adr/0011-container-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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 second function takes the same selection and returns only the distinct specimen types and container types it matches, without loading rows, for callers that list them, such as export's available types.
- 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. 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. When a Subject is listed more than once, an undated entry wins: the Subject is undated. 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.
19 changes: 16 additions & 3 deletions packages/api/src/__tests__/helpers/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
cryovialTube,
staticWell,
paper,
storageContainerTag,
specimenTypeContainerType,
containerTypeUnit,
} from '../../db/schema'
Expand Down Expand Up @@ -128,7 +129,7 @@ export async function createTestContainerTypeUnit(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
Expand All @@ -154,10 +155,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)
*/
Expand Down Expand Up @@ -373,7 +385,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
Expand All @@ -395,7 +407,8 @@ export async function createTestSpecimen(
specimenTypeId,
studySubjectId,
controlBatchId,
created: now,
collectionDate: options.collectionDate,
created: options.created ?? now,
lastUpdated: now,
}).returning()
return result
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/lib/__tests__/identifier-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
resolveContainerByBarcode,
resolveStudiesByShortCodes,
resolveSpecimenTypesByNames,
resolveSubjectNamesByStudy,
} from '../identifier-resolution'
import { SQL_IN_CHUNK_SIZE } from '../sql-helpers'
import { createTestStudy, createTestStudySubject, createTestSpecimenType, createTestStorageContainer, createTestStorageType, createTestLocation } from '../../__tests__/helpers/factories'
import { box, sheet, paper } from '../../db/schema'
import { utcNow } from '../datetime'
Expand Down Expand Up @@ -132,4 +134,28 @@ describe('identifier-resolution lib', () => {
expect(map.get('Serum')).toBe(st.id)
})
})

describe('resolveSubjectNamesByStudy', () => {
it("resolves the Study's Subjects by name, and not another Study's", async () => {
const study = await createTestStudy(testDb, { title: 'S', shortCode: 'S1' })
const other = await createTestStudy(testDb, { title: 'O', shortCode: 'O1' })
const subject = await createTestStudySubject(testDb, { studyId: study.id, name: 'P1' })
await createTestStudySubject(testDb, { studyId: other.id, name: 'P2' })

const map = await resolveSubjectNamesByStudy(testDb, ['P1', 'P2', 'P1'], study.id)

expect(map).toEqual(new Map([['P1', subject.id]]))
})

it('resolves every listed name when the list has more names than SQLite allows as bound parameters', async () => {
const study = await createTestStudy(testDb, { title: 'S', shortCode: 'S1' })
const first = await createTestStudySubject(testDb, { studyId: study.id, name: 'FIRST' })
const last = await createTestStudySubject(testDb, { studyId: study.id, name: 'LAST' })
const unknown = Array.from({ length: SQL_IN_CHUNK_SIZE * 140 }, (_, i) => `UNKNOWN${i}`)

const map = await resolveSubjectNamesByStudy(testDb, ['FIRST', ...unknown, 'LAST'], study.id)

expect(map).toEqual(new Map([['FIRST', first.id], ['LAST', last.id]]))
})
})
})
3 changes: 2 additions & 1 deletion packages/api/src/lib/container-api-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import type {
MicronixSubtypeDetails,
PaperSubtypeDetails,
StaticWellSubtypeDetails,
ContainerSubtype,
} from './container-placement'
import { loadContainerReadViews, type ContainerReadView } from './container-read-view'
import type { CollectionInfo } from '../types/collections'

export type EnrichedContainerApi = StorageContainer & {
containerType: 'micronix_tube' | 'cryovial_tube' | 'paper' | 'static_well' | 'unknown'
containerType: ContainerSubtype
tags: Array<{ id: number; name: string }>
unit: Unit | undefined
location: Location | null
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/lib/container-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { ContainerWriteInput } from '@sampledb/contract'
import type { DatabaseOrTransaction } from './db-types'
import { registerSpecimens, SpecimenRegistrationError } from './specimen-registration'
import { ConflictError } from './error-handler'
import type { ContainerSubtype } from './container-placement'

export type ContainerType = 'micronix_tube' | 'cryovial_tube' | 'paper' | 'static_well'
/** A Container subtype a write can name; `unknown` is only read back. */
export type ContainerType = Exclude<ContainerSubtype, 'unknown'>

export type ContainerQuantity = {
unitId?: number
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/lib/container-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { eq, and, isNull } from 'drizzle-orm'
import { resolveCollection, resolveSheetsByName, type CollectionType } from './collections/collection-resolve'
import { checkGridPositionOccupancy } from './container-occupancy'
import { normalizePosition } from './normalize-position'
import type { ContainerSubtype } from './container-placement'

/** Staging prefix for position updates so swaps on one plate do not violate UNIQUE(collection_id, position) mid-transaction. */
const MOVE_STAGING_PREFIX = '__mv_'
Expand All @@ -22,7 +23,8 @@ function stagingPositionForContainer(containerId: number): string {
return `${MOVE_STAGING_PREFIX}${containerId}`
}

export type ContainerType = 'micronix_tube' | 'cryovial_tube' | 'static_well' | 'paper'
/** A Container subtype a write can name; `unknown` is only read back. */
export type ContainerType = Exclude<ContainerSubtype, 'unknown'>

export interface ContainerInfo {
containerId: number
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/lib/container-placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
} from '../db/schema'
import { eq, inArray } from 'drizzle-orm'

export type ContainerSubtype = 'micronix_tube' | 'cryovial_tube' | 'paper' | 'static_well' | 'unknown'
/** Every Container type, in display order. `'unknown'` is a Container with no subtype row. */
export const CONTAINER_SUBTYPES = ['micronix_tube', 'cryovial_tube', 'paper', 'static_well', 'unknown'] as const

export type ContainerSubtype = (typeof CONTAINER_SUBTYPES)[number]

export type CollectionType = 'micronix_plate' | 'cryovial_box' | 'sheet'

Expand Down
Loading
Loading