diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5868d4c1b..d6dae8879 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14905,11 +14905,14 @@ paths: "403": *a11 "409": *a7 "422": - description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue + description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one - its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; - with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven - (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled. + its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, + `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is + sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is + forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and + the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused + otherwise, never relabelled." content: application/json: schema: @@ -67394,6 +67397,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67446,6 +67459,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67476,8 +67492,11 @@ components: numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The - client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; - `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. + client renders these values and never recomputes the range. `converted` is true when the stored `value` was + converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report + printed (full precision, round for display), and the report's own window was converted with it. A reading stored + as stated has `converted: false` and two nulls. The encrypted note is never echoed in list rows; `hasNote` flags + its presence and the single-resource GET returns the decrypted `note`. LabReferenceRangeOrigin: type: string enum: @@ -67556,6 +67575,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67610,6 +67639,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67820,7 +67852,8 @@ components: - duplicate - unit_mismatch description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other - than the one its marker is tracked in, so it was not written." + than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair + that can be converted is written in the marker's unit and reported with `converted: true`." required: - analyte - reason @@ -67867,6 +67900,16 @@ components: - type: "null" unit: type: string + converted: + type: boolean + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" referenceLow: anyOf: - type: number @@ -67928,6 +67971,9 @@ components: - value - valueText - unit + - converted + - sourceValue + - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow @@ -67945,6 +67991,8 @@ components: - updatedAt additionalProperties: false description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. + `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the + report printed. AiRunEnvelope: type: object properties: diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index 335b152f5..ed5468bf3 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -722,6 +722,9 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.value": OCR_ROW_EXCEPTION, "inserted.valueText": OCR_ROW_EXCEPTION, "inserted.unit": OCR_ROW_EXCEPTION, + "inserted.converted": OCR_ROW_EXCEPTION, + "inserted.sourceValue": OCR_ROW_EXCEPTION, + "inserted.sourceUnit": OCR_ROW_EXCEPTION, "inserted.referenceLow": OCR_ROW_EXCEPTION, "inserted.referenceHigh": OCR_ROW_EXCEPTION, "inserted.catalogReferenceLow": OCR_ROW_EXCEPTION, diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index fa2c7b326..6ef7f3d77 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -192,7 +192,15 @@ export const PUT = apiHandler( const data: Record = {}; if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; - if (d.value !== undefined) data.value = d.value; + if (d.value !== undefined) { + data.value = d.value; + // A number the person typed is no longer the conversion of what the + // report printed, so the "converted from" record would now be wrong. + if (existing.sourceValue !== null || existing.sourceUnit !== null) { + data.sourceValue = null; + data.sourceUnit = null; + } + } // v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a // qualitative row (or `valueText` on a numeric row) crosses the row's type // and is rejected below. An edit of the matching field updates in place. diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 8a8647108..1066edb69 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { serialiseLabResult } from "@/lib/labs/serialise"; @@ -75,7 +76,13 @@ async function isDuplicate( analyte: { equals: row.analyte.trim(), mode: "insensitive" }, takenAt: labReadingDaySearchRange(day), }, - select: { value: true, valueText: true, takenAt: true }, + select: { + value: true, + valueText: true, + sourceValue: true, + sourceUnit: true, + takenAt: true, + }, take: 100, }); const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day); @@ -83,6 +90,19 @@ async function isDuplicate( if (row.value !== undefined && c.value !== null && c.value === row.value) { return true; } + // A reading stored converted holds the value in the marker's unit, so the + // same scan committed again no longer equals it. What the report printed is + // kept beside it: the same number in the same unit is the same reading. + if ( + row.value !== undefined && + row.unit !== undefined && + c.sourceValue !== null && + c.sourceValue === row.value && + c.sourceUnit !== null && + sameLabUnit(c.sourceUnit, row.unit) + ) { + return true; + } if ( row.valueText !== undefined && c.valueText !== null && @@ -179,19 +199,40 @@ async function commitOcrRows(request: NextRequest) { panel: row.panel ?? null, }); - // The marker's unit is the one the reading is stored under, so a row that - // states another unit cannot be written: the number would be kept under a - // unit it is not in. The review screen marks such a row before Save; this - // is the backstop for a client that did not, and for a marker whose unit - // changed between the scan and the commit. A freshly minted marker adopted - // this row's unit, so it cannot disagree. - if ( - !isQualitative && - row.unit !== undefined && - !sameLabUnit(row.unit, biomarker.unit) - ) { - skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" }); - continue; + // The marker's unit is the one the reading is stored under. A row that + // states another unit is converted when the marker's analyte is known and + // the registry has the pair, with what the report printed kept beside it; + // otherwise it cannot be written: the number would be kept under a unit it + // is not in. The review screen marks such a row before Save; this is the + // backstop for a client that did not, and for a marker whose unit changed + // between the scan and the commit. A freshly minted marker adopted this + // row's unit, so it cannot disagree. + let storedValue = row.value ?? null; + let storedSourceLow = isQualitative ? null : sourceLow; + let storedSourceHigh = isQualitative ? null : sourceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = + null; + if (!isQualitative && row.value !== undefined) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: row.value, + unit: row.unit, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, + }); + if (!reading.ok) { + skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" }); + continue; + } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } } // Field-by-field — never spread the parsed row. The row stamps the resolved @@ -202,13 +243,15 @@ async function commitOcrRows(request: NextRequest) { biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value: row.value ?? null, + value: storedValue, valueText: row.valueText ?? null, unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, - sourceReferenceLow: isQualitative ? null : sourceLow, - sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, takenAt: row.takenAt, source: "OCR", diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index bd463890a..e18282a76 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -18,7 +18,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { type ResolvedBiomarker, serialiseLabResult, @@ -232,6 +232,7 @@ async function postLabResult(request: NextRequest) { lowerBound: true, upperBound: true, panel: true, + analyteKey: true, }, }); if (!found) { @@ -253,30 +254,56 @@ async function postLabResult(request: NextRequest) { }); } - // The marker's unit is the one every reading is stored under, so a reading - // that states another unit cannot be written: the number would be kept and - // the label swapped, and nothing downstream could tell. Compared only when a - // unit was actually sent: on the `biomarkerId` path it may be omitted, and - // then the marker's unit is the stated one. A freshly minted marker adopted - // the reading's own unit, so it cannot disagree with it. - if ( - !isQualitative && - unit !== undefined && - !sameLabUnit(unit, biomarker.unit) - ) { - annotate({ - action: { name: "labs.create.unit-mismatch" }, - meta: { biomarkerId: biomarker.id }, + // The marker's unit is the one every reading is stored under. A reading that + // states another unit is converted when the marker's analyte is known and the + // registry has the pair (the value, and the report's own window with it, with + // what the report printed kept beside it); otherwise it cannot be written: + // the number would be kept and the label swapped, and nothing downstream + // could tell. Compared only when a unit was actually sent: on the + // `biomarkerId` path it may be omitted, and then the marker's unit is the + // stated one. A freshly minted marker adopted the reading's own unit, so it + // cannot disagree with it. + let storedValue = value ?? null; + let storedSourceLow = isQualitative ? null : (sourceReferenceLow ?? null); + let storedSourceHigh = isQualitative ? null : (sourceReferenceHigh ?? null); + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null; + if (!isQualitative && value !== undefined) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value, + unit, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, }); - return apiError( - `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, - 422, - { - errorCode: "labs.unit.mismatch", - markerUnit: biomarker.unit, - readingUnit: unit, - }, - ); + if (!reading.ok) { + annotate({ + action: { name: "labs.create.unit-mismatch" }, + meta: { biomarkerId: biomarker.id, reason: reading.reason }, + }); + return apiError( + `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, + 422, + { + errorCode: "labs.unit.mismatch", + markerUnit: biomarker.unit, + readingUnit: unit, + reason: reading.reason, + }, + ); + } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + annotate({ + action: { name: "labs.create.unit-converted" }, + meta: { biomarkerId: biomarker.id }, + }); + } } // Field-by-field assignment — never spread `parsed.data`. The row stamps the @@ -290,17 +317,20 @@ async function postLabResult(request: NextRequest) { panel: biomarker.panel, analyte: biomarker.name, // Exactly one of value / valueText is set (the Zod XOR refine). - value: value ?? null, + value: storedValue, valueText: valueText ?? null, unit: biomarker.unit, + // What the report printed, when the value above is a conversion of it. + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, // The window this reading's own report printed, kept beside the catalog // stamp rather than folded into it: it governs THIS reading's verdict, // and the catalog band stays the net for every reading without one. A // qualitative reading has no window to record. - sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null), - sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null), + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null), takenAt, // v1.25 (iOS #36) — provenance: the on-device-OCR path posts diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 17e2cbc4b..09995ac6a 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -68,6 +68,9 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { value, valueText: null, unit: "mg/dL", + converted: false, + sourceValue: null, + sourceUnit: null, referenceLow: 0, referenceHigh: 100, catalogReferenceLow: 0, diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index b72b4d0df..60a1c2bd7 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -15,6 +15,14 @@ export interface LabResultDto { /** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */ valueText: string | null; unit: string; + /** + * True when the stored number was converted into the marker's unit from + * another one; `sourceValue` and `sourceUnit` are then what the report + * printed. A reading stored as stated has `converted: false` and two nulls. + */ + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; /** * The window this reading is judged against and the one `rangeStatus` was * computed from: the range its own report printed when it carries one, the diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 5563913eb..591ebae47 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -44,22 +44,25 @@ beforeEach(() => { describe("commitApprovedFact — OBSERVATION unit integrity", () => { it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => { + // A pair the registry has no conversion for: ferritin in pmol/L against a + // marker stored in ng/mL (protein molecular weights vary by source). vi.mocked(decryptFactData).mockReturnValue({ - label: "Glucose", - value: 6.1, - unit: "mmol/L", + label: "Ferritin", + value: 450, + unit: "pmol/L", referenceLow: null, referenceHigh: null, effectiveDate: "2025-01-01", } as never); - // Existing marker stored in mg/dL — the resolver returns it as-is. + // Existing marker stored in ng/mL — the resolver returns it as-is. vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ id: "bm-1", - name: "Glucose", - unit: "mg/dL", - lowerBound: 70, - upperBound: 100, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, panel: null, + analyteKey: null, }); await expect( @@ -71,6 +74,69 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { expect(prisma.labResult.create).not.toHaveBeenCalled(); }); + it("converts a unit the marker's analyte can reach, keeping what the document stated", async () => { + vi.mocked(decryptFactData).mockReturnValue({ + label: "Glucose", + value: 6.1, + unit: "mmol/L", + referenceLow: 3.9, + referenceHigh: 5.6, + effectiveDate: "2025-01-01", + } as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + panel: null, + analyteKey: "fasting-glucose", + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-conv", + } as never); + + const ref = await commitApprovedFact("user-1", observationFact()); + expect(ref).toEqual({ recordType: "labResult", recordId: "lr-conv" }); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.value).toBeCloseTo(6.1 * 18.0182, 9); + expect(data.unit).toBe("mg/dL"); + expect(data.sourceValue).toBe(6.1); + expect(data.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(data.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9); + expect(data.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("stores a reading in the marker's own unit without a conversion record", async () => { + vi.mocked(decryptFactData).mockReturnValue({ + label: "Glucose", + value: 95, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + effectiveDate: "2025-01-01", + } as never); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: "fasting-glucose", + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-same", + } as never); + + await commitApprovedFact("user-1", observationFact()); + const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data; + expect(data.value).toBe(95); + expect(data.sourceValue).toBeNull(); + expect(data.sourceUnit).toBeNull(); + }); + it("commits when the stated unit is the marker's unit spelled another way", async () => { vi.mocked(decryptFactData).mockReturnValue({ label: "Glucose", @@ -87,6 +153,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { lowerBound: 70, upperBound: 100, panel: null, + analyteKey: null, }); vi.mocked(prisma.labResult.create).mockResolvedValue({ id: "lr-1", @@ -124,6 +191,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { lowerBound: null, upperBound: null, panel: null, + analyteKey: null, }); vi.mocked(prisma.labResult.create).mockResolvedValue({ id: "lr-2", diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 4645acc0f..a29b4ddc6 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -28,7 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { decryptFactData } from "@/lib/documents/store"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { sameLabUnit } from "@/lib/labs/unit-normalise"; +import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { isUnreadableRange, parseReferenceRange, @@ -128,22 +128,45 @@ async function commitObservation( referenceHigh: isQualitative ? null : sourceHigh, panel: null, }); - // Fail closed on a unit mismatch against an EXISTING marker. A freshly - // minted marker adopts the document's unit, so this only bites when the - // stated unit disagrees with the catalog's authoritative one (e.g. a - // document states "6.1 mmol/L" against a marker stored in mg/dL). Writing - // the stated number under the catalog's unit would corrupt the reading AND - // break the "transcribe verbatim" contract — so reject the fact and let the - // user reconcile on the review screen, exactly like the `unitRequired` gate. + // A unit that differs from an EXISTING marker's is converted when the + // marker's analyte is known and the registry has the pair (a document states + // "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document + // stated beside the stored number. Otherwise it fails closed: writing the + // stated number under the catalog's unit would corrupt the reading AND break + // the "transcribe verbatim" contract — so reject the fact and let the user + // reconcile on the review screen, exactly like the `unitRequired` gate. A + // freshly minted marker adopts the document's unit, so it cannot disagree. // Spelling is compared through the shared normaliser, the same one the // manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`. + let storedValue: number | null = isQualitative + ? null + : (data.value as number); + let storedSourceLow = isQualitative ? null : sourceLow; + let storedSourceHigh = isQualitative ? null : sourceHigh; + let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null; if (!isQualitative) { - if (!sameLabUnit(data.unit as string, biomarker.unit)) { + const reading = await readingInMarkerUnit(prisma, { + marker: biomarker, + value: data.value as number, + unit: data.unit as string, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, + }); + if (!reading.ok) { throw new FactCommitError( "observation.unitMismatch", `The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`, ); } + storedValue = reading.value; + storedSourceLow = reading.sourceReferenceLow; + storedSourceHigh = reading.sourceReferenceHigh; + if (reading.converted) { + convertedFrom = { + sourceValue: reading.sourceValue, + sourceUnit: reading.sourceUnit, + }; + } } const created = await prisma.labResult.create({ data: { @@ -151,15 +174,17 @@ async function commitObservation( biomarkerId: biomarker.id, panel: biomarker.panel, analyte: biomarker.name, - value: isQualitative ? null : (data.value as number), + value: storedValue, valueText: isQualitative ? data.valueText : null, unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, // The report's own window for THIS reading. A qualitative result has no // window to record. - sourceReferenceLow: isQualitative ? null : sourceLow, - sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceLow: storedSourceLow, + sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, takenAt: statedDateOrNow(data.effectiveDate), source: "DOCUMENT", diff --git a/src/lib/labs/__tests__/reading-conversion.test.ts b/src/lib/labs/__tests__/reading-conversion.test.ts new file mode 100644 index 000000000..1c8823b30 --- /dev/null +++ b/src/lib/labs/__tests__/reading-conversion.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readingInMarkerUnit, type ReadingMarker } from "../reading-conversion"; + +/** A client whose `updateMany` records what the fill wrote. */ +function client(current: string | null = null) { + return { + biomarker: { + updateMany: vi.fn(async () => ({ count: 1 })), + findUnique: vi.fn(async () => ({ analyteKey: current })), + }, + } as never; +} + +const glucose: ReadingMarker = { + id: "bm-1", + name: "Glucose", + unit: "mg/dL", + analyteKey: "fasting-glucose", +}; + +const read = ( + marker: ReadingMarker, + value: number, + unit: string | undefined, + low: number | null = null, + high: number | null = null, + c = client(), +) => + readingInMarkerUnit(c, { + marker, + value, + unit, + sourceReferenceLow: low, + sourceReferenceHigh: high, + }); + +describe("a reading in the marker's own unit", () => { + it("is stored as stated, with its window untouched", async () => { + expect(await read(glucose, 95, "mg/dL", 70, 100)).toEqual({ + ok: true, + converted: false, + value: 95, + sourceReferenceLow: 70, + sourceReferenceHigh: 100, + }); + }); + + it("is the same when the unit is spelled another way or not sent", async () => { + expect(await read(glucose, 95, " mg/DL ")).toMatchObject({ + ok: true, + converted: false, + value: 95, + }); + expect(await read(glucose, 95, undefined)).toMatchObject({ + ok: true, + converted: false, + value: 95, + }); + }); + + it("does not touch the database", async () => { + const c = client(); + await read({ ...glucose, analyteKey: null }, 95, "mg/dL", null, null, c); + expect( + (c as never as { biomarker: { updateMany: unknown } }).biomarker + .updateMany, + ).not.toHaveBeenCalled(); + }); +}); + +describe("a reading in another unit", () => { + it("is converted into the marker's unit, keeping what the report printed", async () => { + const result = await read(glucose, 5.6, "mmol/L"); + expect(result).toMatchObject({ + ok: true, + converted: true, + sourceValue: 5.6, + sourceUnit: "mmol/L", + }); + if (!result.ok) return; + expect(result.value).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("stores full precision and trims the printed unit", async () => { + const result = await read(glucose, 5.6, " mmol/L "); + if (!result.ok || !result.converted) throw new Error("not converted"); + expect(result.sourceUnit).toBe("mmol/L"); + expect(result.value).not.toBe(Math.round(result.value)); + }); + + it("moves the report's own window with the value", async () => { + const result = await read(glucose, 5.6, "mmol/L", 3.9, 5.6); + if (!result.ok) throw new Error("refused"); + expect(result.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9); + expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + expect(result.sourceReferenceLow!).toBeLessThan( + result.sourceReferenceHigh!, + ); + }); + + it("leaves an open bound open", async () => { + const result = await read(glucose, 5, "mmol/L", null, 5.6); + if (!result.ok) throw new Error("refused"); + expect(result.sourceReferenceLow).toBeNull(); + expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9); + }); + + it("converts an affine pair: 47.5 mmol/mol is 6.5 % HbA1c", async () => { + const hba1c: ReadingMarker = { + id: "bm-2", + name: "HbA1c", + unit: "%", + analyteKey: "hba1c", + }; + const result = await read(hba1c, 47.5, "mmol/mol", 20, 42); + if (!result.ok) throw new Error("refused"); + expect(result.value).toBeCloseTo(6.5, 1); + expect(result.sourceReferenceLow!).toBeLessThan( + result.sourceReferenceHigh!, + ); + }); + + it("converts by scale alone, for a marker with no analyte", async () => { + const unknown: ReadingMarker = { + id: "bm-3", + name: "Some marker", + unit: "mg/dL", + analyteKey: null, + }; + const result = await read(unknown, 1, "g/L"); + expect(result).toMatchObject({ ok: true, converted: true, value: 100 }); + }); +}); + +describe("the analyte is filled on first use", () => { + it("links a marker that had no key yet, then converts", async () => { + const c = client(); + const result = await read( + { id: "bm-1", name: "Blutzucker", unit: "mg/dL", analyteKey: null }, + 5.6, + "mmol/L", + null, + null, + c, + ); + expect(result).toMatchObject({ ok: true, converted: true }); + const updateMany = ( + c as never as { + biomarker: { updateMany: ReturnType }; + } + ).biomarker.updateMany; + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "bm-1", analyteKey: null }, + data: { analyteKey: "fasting-glucose" }, + }); + }); +}); + +describe("a refusal", () => { + it("is returned for a marker with no analyte and a different kind of unit", async () => { + const free: ReadingMarker = { + id: "bm-4", + name: "My own marker", + unit: "mg/dL", + analyteKey: null, + }; + expect(await read(free, 5.6, "mmol/L")).toEqual({ + ok: false, + reason: "unknown_analyte", + }); + }); + + it("is returned for a pair the analyte's entry does not reach", async () => { + expect(await read(glucose, 5.6, "%")).toEqual({ + ok: false, + reason: "unknown_unit", + }); + }); + + it("is returned for a value that is not finite", async () => { + expect(await read(glucose, Number.NaN, "mmol/L")).toEqual({ + ok: false, + reason: "not_finite", + }); + }); +}); diff --git a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts index e11976666..d6b1d3719 100644 --- a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts +++ b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts @@ -38,6 +38,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: null, upperBound: 100, panel: "lipids", + analyteKey: null, }); const out = await resolveOrMintBiomarker("u1", { @@ -65,6 +66,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: null, upperBound: 116, panel: "lipids", + analyteKey: null, }); const out = await resolveOrMintBiomarker("u1", baseInput); @@ -90,6 +92,7 @@ describe("resolveOrMintBiomarker", () => { lowerBound: 30, upperBound: 400, panel: null, + analyteKey: null, }); await resolveOrMintBiomarker("u1", { diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index d987fc8ba..c6ca11e3a 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -65,6 +65,7 @@ export interface MintedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; } /** @@ -102,6 +103,7 @@ export async function resolveOrMintBiomarker( lowerBound: true, upperBound: true, panel: true, + analyteKey: true, } as const; // Case-insensitive match on the catalog identity. Postgres is the store; diff --git a/src/lib/labs/reading-conversion.ts b/src/lib/labs/reading-conversion.ts new file mode 100644 index 000000000..105650d4d --- /dev/null +++ b/src/lib/labs/reading-conversion.ts @@ -0,0 +1,105 @@ +/** + * A lab reading, written in its marker's unit. + * + * Every reading is stored under the unit of its marker. A reading that states + * the same unit (spelling aside) goes in as it is. One that states another is + * converted when the marker's analyte is known and the registry has the pair, + * keeping what the report printed beside it (`sourceValue`, `sourceUnit`) so a + * number nobody typed can always be traced back. Anything else is refused, + * exactly as before: the number is never kept under a unit it is not in. + * + * The report's own reference window is in the unit the report printed, so it is + * converted with the value; a verdict that compared a converted value with an + * unconverted window would be wrong. Full precision is stored, rounding is for + * display. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { ensureAnalyteKey } from "./analyte-key-store"; +import { + convertLabRange, + convertLabValue, + type ConversionRefusal, +} from "./unit-conversion/convert"; +import { sameLabUnit } from "./unit-normalise"; + +export interface ReadingMarker { + id: string; + name: string; + unit: string; + analyteKey?: string | null; +} + +export type ReadingInMarkerUnit = + | { + ok: true; + converted: false; + value: number; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + } + | { + ok: true; + converted: true; + value: number; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + /** The value as the report printed it. */ + sourceValue: number; + /** The unit as the report printed it, trimmed. */ + sourceUnit: string; + } + | { ok: false; reason: ConversionRefusal }; + +export async function readingInMarkerUnit( + client: Pick, + input: { + marker: ReadingMarker; + value: number; + /** The unit the reading states; undefined means the marker's own. */ + unit: string | undefined; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + }, +): Promise { + const { marker, value, unit, sourceReferenceLow, sourceReferenceHigh } = + input; + if (unit === undefined || sameLabUnit(unit, marker.unit)) { + return { + ok: true, + converted: false, + value, + sourceReferenceLow, + sourceReferenceHigh, + }; + } + + const analyteKey = await ensureAnalyteKey(client, marker); + const converted = convertLabValue({ + analyteKey, + value, + from: unit, + to: marker.unit, + }); + if (!converted.ok) return { ok: false, reason: converted.reason }; + + // The printed window is in the printed unit; move it with the value. + const range = convertLabRange({ + analyteKey, + low: sourceReferenceLow, + high: sourceReferenceHigh, + from: unit, + to: marker.unit, + }); + if (!range.ok) return { ok: false, reason: range.reason }; + + return { + ok: true, + converted: true, + value: converted.value, + sourceReferenceLow: range.low, + sourceReferenceHigh: range.high, + sourceValue: value, + sourceUnit: unit.trim(), + }; +} diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index 2dc6f6ced..cc73c7bec 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -53,6 +53,8 @@ export interface ResolvedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; + /** Which analyte the marker is (unit-conversion registry key), if known. */ + analyteKey?: string | null; } /** The lab-result row shape the resolver reads (Prisma row subset). */ @@ -74,6 +76,12 @@ export interface LabRow { takenAt: Date; source: string; biomarkerId: string | null; + /** + * When the reading was converted into the marker's unit: the value and unit + * as the report printed them. Both null for a reading stored as stated. + */ + sourceValue?: number | null; + sourceUnit?: string | null; noteEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -179,6 +187,7 @@ export function serialiseLabResult( biomarker?: ResolvedBiomarker | null, ) { const resolved = resolveLabFields(row, biomarker); + const conversion = conversionFields(row); return { id: row.id, biomarkerId: row.biomarkerId, @@ -187,6 +196,9 @@ export function serialiseLabResult( value: row.value, valueText: row.valueText, unit: resolved.unit, + converted: conversion.converted, + sourceValue: conversion.sourceValue, + sourceUnit: conversion.sourceUnit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, catalogReferenceLow: resolved.catalogReferenceLow, @@ -210,6 +222,20 @@ export function serialiseLabResult( }; } +/** + * Whether the stored number is a conversion, and what the report printed. A + * reading stored as stated reports `converted: false` and two nulls. + */ +function conversionFields(row: Pick) { + const sourceValue = row.sourceValue ?? null; + const sourceUnit = row.sourceUnit ?? null; + return { + converted: sourceValue !== null && sourceUnit !== null, + sourceValue, + sourceUnit, + }; +} + /** * Serialise a lab row to the detail DTO — same as the list DTO but carries the * decrypted `note` instead of the `hasNote` flag. @@ -220,6 +246,7 @@ export function serialiseLabResultDetail( note: string | null, ) { const resolved = resolveLabFields(row, biomarker); + const conversion = conversionFields(row); return { id: row.id, biomarkerId: row.biomarkerId, @@ -228,6 +255,9 @@ export function serialiseLabResultDetail( value: row.value, valueText: row.valueText, unit: resolved.unit, + converted: conversion.converted, + sourceValue: conversion.sourceValue, + sourceUnit: conversion.sourceUnit, referenceLow: resolved.referenceLow, referenceHigh: resolved.referenceHigh, catalogReferenceLow: resolved.catalogReferenceLow, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index ca0b92046..bedfc0d09 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -70,6 +70,9 @@ const labResultRow = z value: z.number().nullable(), valueText: z.string().nullable(), unit: z.string(), + converted: z.boolean(), + sourceValue: z.number().nullable(), + sourceUnit: z.string().nullable(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), catalogReferenceLow: z.number().nullable(), @@ -89,7 +92,7 @@ const labResultRow = z .meta({ id: "LabResult", description: - "A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.", + "A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. `converted` is true when the stored `value` was converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report printed (full precision, round for display), and the report's own window was converted with it. A reading stored as stated has `converted: false` and two nulls. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.", }); const labResultDetail = labResultRow @@ -164,7 +167,7 @@ export const labsPaths: NonNullable = { ...stdResponses, "422": { description: - "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.", + "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused otherwise, never relabelled.", content: { "application/json": { schema: errorEnvelope } }, }, ...recordWriteRateLimitResponse, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 9de42ea6d..94ffe0c80 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -115,6 +115,9 @@ const committedRow = z value: z.number().nullable(), valueText: z.string().nullable(), unit: z.string(), + converted: z.boolean(), + sourceValue: z.number().nullable(), + sourceUnit: z.string().nullable(), referenceLow: z.number().nullable(), referenceHigh: z.number().nullable(), catalogReferenceLow: z.number().nullable(), @@ -134,7 +137,7 @@ const committedRow = z .meta({ id: "OcrCommittedLabResult", description: - "A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.", + "A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the report printed.", }); const commitResponse = z @@ -145,7 +148,7 @@ const commitResponse = z analyte: z.string(), reason: z.enum(["duplicate", "unit_mismatch"]).meta({ description: - "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in, so it was not written.", + "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.", }), }), ), diff --git a/tests/integration/lab-unit-mismatch.test.ts b/tests/integration/lab-unit-mismatch.test.ts index 0596330b7..06a926202 100644 --- a/tests/integration/lab-unit-mismatch.test.ts +++ b/tests/integration/lab-unit-mismatch.test.ts @@ -9,6 +9,13 @@ * not: a different spelling of the same unit, a qualitative reading, a * reading with no unit on the structured path and a brand-new marker must all * still write. + * + * "Another unit" is refused only where the pair cannot be converted. Ferritin + * in pmol/L against a marker kept in ng/mL is the standing example: protein + * molecular weights vary by source, so the registry has no conversion and the + * reading stays refused. Where the marker's analyte is known and the pair is in + * the registry (glucose in mmol/L against mg/dL) the reading is converted + * instead; that is `labs-unit-conversion.test.ts`. */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -74,6 +81,19 @@ async function seedGlucose() { }); } +/** A marker whose analyte the registry has no conversion for. */ +async function seedFerritin() { + return getPrismaClient().biomarker.create({ + data: { + userId: USER_ID, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + }, + }); +} + function jsonReq(path: string, body: unknown): NextRequest { return new NextRequest(`http://localhost${path}`, { method: "POST", @@ -103,27 +123,28 @@ const labCount = () => describe("POST /api/labs", () => { it("refuses a reading in another unit than its marker's and writes nothing", async () => { - await seedGlucose(); + await seedFerritin(); const { status, body } = await postLab({ - analyte: "Glucose", - value: 5.6, - unit: "mmol/L", + analyte: "Ferritin", + value: 450, + unit: "pmol/L", }); expect(status).toBe(422); expect(body.meta).toMatchObject({ errorCode: "labs.unit.mismatch", - markerUnit: "mg/dL", - readingUnit: "mmol/L", + markerUnit: "ng/mL", + readingUnit: "pmol/L", + reason: "unknown_analyte", }); expect(await labCount()).toBe(0); }); it("refuses it on the structured path too, when a unit is sent", async () => { - const marker = await seedGlucose(); + const marker = await seedFerritin(); const { status, body } = await postLab({ biomarkerId: marker.id, - value: 5.6, - unit: "mmol/L", + value: 450, + unit: "pmol/L", }); expect(status).toBe(422); expect(body.meta.errorCode).toBe("labs.unit.mismatch"); @@ -166,30 +187,30 @@ describe("POST /api/labs", () => { it("lets a new marker adopt the unit of its first reading", async () => { const { status, body } = await postLab({ - analyte: "Creatinine", + analyte: "Ferritin", value: 80, - unit: "µmol/L", + unit: "ng/mL", }); expect(status).toBe(201); - expect(body.data.unit).toBe("µmol/L"); + expect(body.data.unit).toBe("ng/mL"); // …and then holds the next reading in another unit to it. const next = await postLab({ - analyte: "Creatinine", - value: 0.9, - unit: "mg/dL", + analyte: "Ferritin", + value: 180, + unit: "pmol/L", }); expect(next.status).toBe(422); expect(await labCount()).toBe(1); }); it("never judges the refused value against the marker's range", async () => { - // The case from the report: 5.6 mmol/L is an ordinary glucose, and 5.6 - // mg/dL would read as far below range. - await seedGlucose(); - await postLab({ analyte: "Glucose", value: 5.6, unit: "mmol/L" }); + // The case from the report, for a pair that is refused: the number must + // never be kept under a unit it is not in and judged against that band. + await seedFerritin(); + await postLab({ analyte: "Ferritin", value: 450, unit: "pmol/L" }); const { GET } = await import("@/app/api/labs/route"); const res = await GET( - new NextRequest("http://localhost/api/labs?analyte=Glucose"), + new NextRequest("http://localhost/api/labs?analyte=Ferritin"), ); expect((await res.json()).data.results).toEqual([]); }); @@ -197,30 +218,30 @@ describe("POST /api/labs", () => { describe("POST /api/labs/ocr/commit", () => { it("writes the matching row, skips the one in another unit, and reports a partial outcome", async () => { - await seedGlucose(); + await seedFerritin(); const { status, body } = await commitRows([ - { analyte: "Glucose", value: 95, unit: "mg/dL" }, + { analyte: "Ferritin", value: 95, unit: "ng/mL" }, { - analyte: "Glucose", - value: 5.6, - unit: "mmol/L", + analyte: "Ferritin", + value: 450, + unit: "pmol/L", takenAt: "2026-08-10T12:00:00.000Z", }, ]); expect(status).toBe(200); expect(body.data.inserted).toHaveLength(1); - expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "mg/dL" }); + expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "ng/mL" }); expect(body.data.skipped).toEqual([ - { analyte: "Glucose", reason: "unit_mismatch" }, + { analyte: "Ferritin", reason: "unit_mismatch" }, ]); expect(body.data.outcome).toBe("partial"); expect(await labCount()).toBe(1); }); it("reports a failed outcome when every row is in another unit", async () => { - await seedGlucose(); + await seedFerritin(); const { body } = await commitRows([ - { analyte: "Glucose", value: 5.6, unit: "mmol/L" }, + { analyte: "Ferritin", value: 450, unit: "pmol/L" }, ]); expect(body.data.inserted).toHaveLength(0); expect(body.data.outcome).toBe("failed"); diff --git a/tests/integration/labs-unit-conversion.test.ts b/tests/integration/labs-unit-conversion.test.ts new file mode 100644 index 000000000..b6199c01d --- /dev/null +++ b/tests/integration/labs-unit-conversion.test.ts @@ -0,0 +1,316 @@ +/** + * A lab reading in another unit is converted into its marker's unit (#1095). + * + * Until now a reading that stated a unit other than its marker's was refused. + * When the marker's analyte is known and the registry has the pair, the three + * write paths now convert it, keep what the report printed beside it, and move + * the report's own reference window with the value. Against a real Postgres: + * + * - POST /api/labs converts, stores full precision and the printed value and + * unit, converts the printed window, and says so in the response; + * - a pair that cannot be converted is still the 422 it always was, now with + * the reason; + * - the verdict is judged against the converted window, so a value in range in + * the printed unit is in range after conversion; + * - the OCR commit converts, and the same scan committed again is a duplicate + * (the stored number is converted, so the printed one is what matches); + * - editing the value of a converted reading clears the conversion record; + * - the list returns the fields. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "lab-conversion-user"; +const MGDL_PER_MMOL = 18.0182; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); + // A catalog marker the user tracks in mg/dL, with a catalog band. + await prisma.biomarker.create({ + data: { + id: "bm-glucose", + userId: USER, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }, + }); + // A marker the registry knows nothing about. + await prisma.biomarker.create({ + data: { + id: "bm-free", + userId: USER, + name: "My own marker", + unit: "mg/dL", + }, + }); +}); + +async function call( + route: "labs" | "ocr" | "one", + method: "GET" | "POST" | "PUT", + body?: unknown, + id?: string, +): Promise<{ + status: number; + json: { data?: T; meta?: Record }; +}> { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const url = + route === "labs" + ? "http://localhost/api/labs" + : route === "ocr" + ? "http://localhost/api/labs/ocr/commit" + : `http://localhost/api/labs/${id}`; + const init = { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }; + const req = new NextRequest(url, init); + let res: Response; + if (route === "labs") { + const mod = await import("@/app/api/labs/route"); + res = await (method === "GET" ? mod.GET(req) : mod.POST(req)); + } else if (route === "ocr") { + const mod = await import("@/app/api/labs/ocr/commit/route"); + res = await mod.POST(req); + } else { + const mod = await import("@/app/api/labs/[id]/route"); + res = await (method === "PUT" + ? mod.PUT(req, { params: Promise.resolve({ id: id! }) }) + : mod.GET(req, { params: Promise.resolve({ id: id! }) })); + } + return { status: res.status, json: (await res.json()) as never }; +} + +type Dto = { + id: string; + value: number; + unit: string; + converted: boolean; + sourceValue: number | null; + sourceUnit: string | null; + referenceLow: number | null; + referenceHigh: number | null; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + rangeStatus: string; +}; + +describe("POST /api/labs", () => { + it("converts a reading in another unit and keeps what the report printed", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-01T08:00:00.000Z", + }); + expect(status).toBe(201); + const dto = json.data!; + expect(dto.converted).toBe(true); + expect(dto.unit).toBe("mg/dL"); + expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(dto.sourceValue).toBe(5.6); + expect(dto.sourceUnit).toBe("mmol/L"); + // The report's own window moved with the value. + expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9); + // And the verdict is judged against it: 5.6 of 3.9-6.0 is in range. + expect(dto.rangeStatus).toBe("in-range"); + + const row = await prisma.labResult.findUniqueOrThrow({ + where: { id: dto.id }, + }); + expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + expect(row.sourceValue).toBe(5.6); + expect(row.sourceUnit).toBe("mmol/L"); + }); + + it("flags a converted reading outside the printed window as such", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 7.2, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 6.0, + takenAt: "2026-05-02T08:00:00.000Z", + }); + expect(json.data!.rangeStatus).toBe("above"); + }); + + it("stores a reading in the marker's unit as stated, with no conversion record", async () => { + const { json } = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 95, + unit: "mg/dL", + takenAt: "2026-05-03T08:00:00.000Z", + }); + const dto = json.data!; + expect(dto.converted).toBe(false); + expect(dto.value).toBe(95); + expect(dto.sourceValue).toBeNull(); + expect(dto.sourceUnit).toBeNull(); + }); + + it("links a marker that had no key yet, the first time it is needed", async () => { + const before = await prisma.biomarker.findUniqueOrThrow({ + where: { id: "bm-glucose" }, + }); + // The marker was created without a key in this test; the first conversion + // above has already filled it. + expect(before.analyteKey).toBe("fasting-glucose"); + }); + + it("still refuses a pair that cannot be converted, now with the reason", async () => { + const { status, json } = await call("labs", "POST", { + biomarkerId: "bm-free", + value: 5.6, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(status).toBe(422); + expect(json.meta).toMatchObject({ + errorCode: "labs.unit.mismatch", + markerUnit: "mg/dL", + readingUnit: "mmol/L", + reason: "unknown_analyte", + }); + expect( + await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }), + ).toBe(0); + }); +}); + +describe("the OCR commit", () => { + const row = { + analyte: "Glucose", + value: 5.0, + unit: "mmol/L", + referenceText: "3.9 - 5.6", + takenAt: "2026-06-01T08:00:00.000Z", + }; + + it("converts the row and reports it", async () => { + const { status, json } = await call<{ + inserted: Dto[]; + skipped: unknown[]; + }>("ocr", "POST", { rows: [row] }); + expect(status).toBe(200); + expect(json.data!.skipped).toEqual([]); + const [inserted] = json.data!.inserted; + expect(inserted.converted).toBe(true); + expect(inserted.unit).toBe("mg/dL"); + expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9); + expect(inserted.sourceValue).toBe(5.0); + expect(inserted.sourceUnit).toBe("mmol/L"); + expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9); + expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9); + }); + + it("treats the same scan committed again as a duplicate", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { rows: [row] }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]); + }); + + it("skips a row whose unit cannot be converted, as before", async () => { + const { json } = await call<{ + inserted: unknown[]; + skipped: Array<{ reason: string }>; + }>("ocr", "POST", { + rows: [ + { + analyte: "My own marker", + value: 3, + unit: "mmol/L", + takenAt: "2026-06-02T08:00:00.000Z", + }, + ], + }); + expect(json.data!.inserted).toEqual([]); + expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]); + }); +}); + +describe("editing and reading back", () => { + it("clears the conversion record when the value is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.0, + unit: "mmol/L", + takenAt: "2026-07-01T08:00:00.000Z", + }); + const id = created.json.data!.id; + expect(created.json.data!.converted).toBe(true); + + const edited = await call("one", "PUT", { value: 110 }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.value).toBe(110); + expect(edited.json.data!.converted).toBe(false); + expect(edited.json.data!.sourceValue).toBeNull(); + expect(edited.json.data!.sourceUnit).toBeNull(); + }); + + it("keeps the conversion record when only the note is edited", async () => { + const created = await call("labs", "POST", { + biomarkerId: "bm-glucose", + value: 6.1, + unit: "mmol/L", + takenAt: "2026-07-02T08:00:00.000Z", + }); + const id = created.json.data!.id; + const edited = await call("one", "PUT", { note: "fasting" }, id); + expect(edited.status).toBe(200); + expect(edited.json.data!.converted).toBe(true); + expect(edited.json.data!.sourceValue).toBe(6.1); + }); + + it("returns the fields on the list and on the single reading", async () => { + const list = await call<{ results: Dto[] }>("labs", "GET"); + const converted = list.json.data!.results.filter((r) => r.converted); + expect(converted.length).toBeGreaterThan(0); + for (const r of converted) { + expect(r.sourceValue).not.toBeNull(); + expect(r.sourceUnit).not.toBeNull(); + } + const one = await call("one", "GET", undefined, converted[0].id); + expect(one.json.data!.converted).toBe(true); + }); +});