diff --git a/.trivyignore b/.trivyignore index ccf17286e..34a98f1b7 100644 --- a/.trivyignore +++ b/.trivyignore @@ -31,3 +31,11 @@ CVE-2026-48815 # entry, the matching `auditConfig.ignoreGhsas` entry in pnpm-workspace.yaml, # and raise the floor once node-forge 1.4.1 is out. CVE-2026-85393 + +# braces <= 3.0.3 stack overflow on deeply nested patterns (CVE-2026-93687). +# braces arrives only through micromatch -> fast-glob in development tools +# (@next/eslint-plugin-next, @ts-morph/common, the shadcn CLI); `pnpm why +# braces --prod` is empty and the production image does not contain it, and +# those tools only expand patterns written in this repository. No fixed +# release is published (latest is 3.0.3). Remove this entry once one is. +CVE-2026-93687 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 611091141..d270f2e96 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14064,7 +14064,16 @@ paths: "401": *a1 "403": *a10 "409": *a7 - "422": *a4 + "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. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a11 /api/labs/{id}: get: @@ -14265,9 +14274,9 @@ paths: - Labs summary: Commit confirmed Lab-OCR rows description: 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped - biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped. - Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body - field.' + biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading, or states a unit + other than that of its marker, is skipped (`skipped[].reason`). Idempotent (Idempotency-Key). Audits as + `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.' parameters: - *a6 requestBody: @@ -31762,15 +31771,16 @@ components: labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, - labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, - labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, - labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — - managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, - managed_profile.guardian.required, managed_profile.not_found; measurement — measurement.batch.invalid, - measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, - measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, - measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; - medication — medication.intake.import.invalid_format; medications — + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, + labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, + labs.update.sourceReferenceRangeInvalid; managed_profile — managed_profile.guardian.duplicate, + managed_profile.guardian.managed_invitee, managed_profile.guardian.required, managed_profile.not_found; + measurement — measurement.batch.invalid, measurement.batch.source_not_permitted, + measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, + measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, + measurement.restore.invalid, measurement.update.server_owned_source; medication — + medication.intake.import.invalid_format; medications — medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, medications.intake.bulk.too_large, medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, @@ -63380,6 +63390,13 @@ components: enum: - new - existing + markerUnit: + anyOf: + - type: string + - type: "null" + description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this + row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: + unit_mismatch`, and the review screen marks it before Save." duplicateOf: anyOf: - type: string @@ -63395,6 +63412,7 @@ components: - takenAt - confidence - biomarkerMatch + - markerUnit - duplicateOf additionalProperties: false description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side: @@ -63435,7 +63453,11 @@ components: type: string reason: type: string - const: duplicate + enum: + - 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." required: - analyte - reason @@ -63455,7 +63477,8 @@ components: - skipped - outcome additionalProperties: false - description: 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.' + description: 'The rows written (`source: "OCR"`), those skipped (commit-time duplicates, or a unit that differs from + that of the marker), and the resolved outcome.' OcrCommittedLabResult: type: object properties: diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts index 72d49564f..54267f858 100644 --- a/e2e/dialog-horizontal-overflow.spec.ts +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -363,6 +363,7 @@ const REVIEW_ROWS = ["LDL", "HDL", "Glucose", "Creatinine", "ALT", "CRP"].map( takenAt: index === 0 ? null : "2026-06-10", confidence: { analyte: 0.9, value: 0.9, unit: 0.9, range: 0.9 }, biomarkerMatch: "existing", + markerUnit: "mmol/L", duplicateOf: null, }), ); diff --git a/messages/de.json b/messages/de.json index d319c56a1..64d0b6e38 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9075,6 +9075,10 @@ "errorValue": "Bitte einen Wert angeben.", "errorResult": "Bitte ein Ergebnis angeben.", "errorUnit": "Bitte eine Einheit angeben.", + "errorUnitMismatch": "Dieser Marker wird in {markerUnit} geführt. Ändere die Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.", + "unitDiffersBadge": "Geführt in {markerUnit}; dieser Befund nennt {unit}", + "savedPartialUnitToast": "{count} Laborwerte gespeichert. Nicht gespeichert, weil die Einheit von der des Markers abweicht: {analytes}", + "savedNothingUnitToast": "Nichts gespeichert: Die Einheit weicht bei {analytes} von der des Markers ab", "saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.", "providerUnsupported": "Dein KI-Anbieter kann keine Bilder lesen. Konfiguriere ein Modell mit Bildverständnis.", "consentRequired": "Für das Scannen eines Befunds ist die Einwilligung zur KI-Verarbeitung erforderlich.", diff --git a/messages/en.json b/messages/en.json index d077dfde3..1e8f3ae7c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9075,6 +9075,10 @@ "errorValue": "Add a value.", "errorResult": "Add a result.", "errorUnit": "Add a unit.", + "errorUnitMismatch": "This marker is tracked in {markerUnit}. Change the unit if the report was misread, or deselect this reading.", + "unitDiffersBadge": "Tracked in {markerUnit}; this report says {unit}", + "savedPartialUnitToast": "{count} lab results saved. Not saved, because the unit differs from the marker's: {analytes}", + "savedNothingUnitToast": "Nothing saved: the unit differs from the marker's for {analytes}", "saveFailed": "Couldn't save the readings. Please try again.", "providerUnsupported": "Your AI provider can't read images. Configure a vision-capable model.", "consentRequired": "AI processing consent is required to scan a report.", diff --git a/messages/es.json b/messages/es.json index b1038b4e0..87c229f67 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9075,6 +9075,10 @@ "errorValue": "Añade un valor.", "errorResult": "Añade un resultado.", "errorUnit": "Añade una unidad.", + "errorUnitMismatch": "Este marcador se registra en {markerUnit}. Cambia la unidad si el informe se leyó mal o deselecciona este valor.", + "unitDiffersBadge": "Registrado en {markerUnit}; este informe indica {unit}", + "savedPartialUnitToast": "{count} resultados guardados. No guardados porque la unidad difiere de la del marcador: {analytes}", + "savedNothingUnitToast": "No se guardó nada: la unidad difiere de la del marcador en {analytes}", "saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.", "providerUnsupported": "Tu proveedor de IA no puede leer imágenes. Configura un modelo con visión.", "consentRequired": "Se requiere consentimiento de procesamiento de IA para escanear un informe.", diff --git a/messages/fr.json b/messages/fr.json index 9a8f0222e..97364f463 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9075,6 +9075,10 @@ "errorValue": "Indiquez une valeur.", "errorResult": "Indiquez un résultat.", "errorUnit": "Indiquez une unité.", + "errorUnitMismatch": "Ce marqueur est suivi en {markerUnit}. Modifiez l’unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.", + "unitDiffersBadge": "Suivi en {markerUnit} ; ce compte rendu indique {unit}", + "savedPartialUnitToast": "{count} résultats enregistrés. Non enregistrés, car l’unité diffère de celle du marqueur : {analytes}", + "savedNothingUnitToast": "Rien n’a été enregistré : l’unité diffère de celle du marqueur pour {analytes}", "saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.", "providerUnsupported": "Votre fournisseur d'IA ne peut pas lire les images. Configurez un modèle capable de vision.", "consentRequired": "Le consentement au traitement par IA est requis pour scanner un compte rendu.", diff --git a/messages/it.json b/messages/it.json index 13192eaee..3f40e06b7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9075,6 +9075,10 @@ "errorValue": "Inserisci un valore.", "errorResult": "Inserisci un risultato.", "errorUnit": "Inserisci un'unità.", + "errorUnitMismatch": "Questo marcatore è registrato in {markerUnit}. Cambia l’unità se il referto è stato letto male, oppure deseleziona questo valore.", + "unitDiffersBadge": "Registrato in {markerUnit}; questo referto indica {unit}", + "savedPartialUnitToast": "{count} risultati salvati. Non salvati perché l’unità è diversa da quella del marcatore: {analytes}", + "savedNothingUnitToast": "Nulla salvato: l’unità è diversa da quella del marcatore per {analytes}", "saveFailed": "Impossibile salvare i valori. Riprova.", "providerUnsupported": "Il tuo provider IA non può leggere le immagini. Configura un modello con visione.", "consentRequired": "Per scansionare un referto è richiesto il consenso al trattamento tramite IA.", diff --git a/messages/ko.json b/messages/ko.json index 271f03069..6ed108d52 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9075,6 +9075,10 @@ "errorValue": "값을 입력하세요.", "errorResult": "결과를 입력하세요.", "errorUnit": "단위를 입력하세요.", + "errorUnitMismatch": "이 지표는 {markerUnit} 단위로 기록됩니다. 결과지를 잘못 읽었다면 단위를 바꾸거나 이 값의 선택을 해제하세요.", + "unitDiffersBadge": "{markerUnit} 단위로 기록 중; 이 결과지는 {unit}", + "savedPartialUnitToast": "검사 결과 {count}개를 저장했습니다. 단위가 지표와 달라 저장하지 않은 항목: {analytes}", + "savedNothingUnitToast": "저장된 항목이 없습니다: {analytes}의 단위가 지표와 다릅니다", "saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.", "providerUnsupported": "AI 제공자가 이미지를 읽지 못해요. 이미지를 읽는 모델로 설정해 주세요.", "consentRequired": "결과지를 스캔하려면 AI 처리 동의가 필요해요.", diff --git a/messages/pl.json b/messages/pl.json index 388671013..84bb9d1dc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9075,6 +9075,10 @@ "errorValue": "Podaj wartość.", "errorResult": "Podaj wynik.", "errorUnit": "Podaj jednostkę.", + "errorUnitMismatch": "Ten wskaźnik jest prowadzony w {markerUnit}. Zmień jednostkę, jeśli wynik został źle odczytany, albo odznacz tę wartość.", + "unitDiffersBadge": "Prowadzony w {markerUnit}; ten wynik podaje {unit}", + "savedPartialUnitToast": "Zapisano {count} wyników. Nie zapisano, bo jednostka różni się od jednostki wskaźnika: {analytes}", + "savedNothingUnitToast": "Nic nie zapisano: jednostka różni się od jednostki wskaźnika dla {analytes}", "saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.", "providerUnsupported": "Twój dostawca AI nie może odczytywać obrazów. Skonfiguruj model z obsługą obrazu.", "consentRequired": "Do zeskanowania wyniku wymagana jest zgoda na przetwarzanie przez AI.", diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 6f193ef36..8a8647108 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 { sameLabUnit } from "@/lib/labs/unit-normalise"; import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { serialiseLabResult } from "@/lib/labs/serialise"; import { @@ -178,6 +179,21 @@ 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; + } + // Field-by-field — never spread the parsed row. The row stamps the resolved // catalog name/unit/range as historical truth and keeps the FK. const created = await prisma.labResult.create({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index d4fbf47d9..bd463890a 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -18,6 +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 { type ResolvedBiomarker, serialiseLabResult, @@ -252,6 +253,32 @@ 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 }, + }); + 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, + }, + ); + } + // Field-by-field assignment — never spread `parsed.data`. The row stamps the // resolved name/unit/range as historical truth (so a later catalog edit does // not silently rewrite a past reading) AND keeps the FK; reads resolve the diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index 852746337..88d61a6e3 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -12,6 +12,7 @@ import { handleFilePickerChange, OcrPageCoverageNote, OcrReviewDialog, + unitSkippedAnalytes, } from "../ocr-review-dialog"; const hookState = vi.hoisted(() => ({ extractPending: false })); @@ -223,3 +224,40 @@ describe(" — a long PDF read from its first pages", () => expect(html).toBe(""); }); }); + +describe("unitSkippedAnalytes", () => { + const unit = (analyte: string) => ({ + analyte, + reason: "unit_mismatch" as const, + }); + + it("is null when nothing was skipped for a unit", () => { + expect(unitSkippedAnalytes([])).toBeNull(); + expect( + unitSkippedAnalytes([{ analyte: "LDL", reason: "duplicate" }]), + ).toBeNull(); + }); + + it("names the analytes skipped for a unit, ignoring duplicates", () => { + expect( + unitSkippedAnalytes([ + { analyte: "LDL", reason: "duplicate" }, + unit("Glucose"), + unit("Creatinine"), + ]), + ).toBe("Glucose, Creatinine"); + }); + + it("lists three and counts the rest, once each", () => { + expect( + unitSkippedAnalytes([ + unit("A"), + unit("A"), + unit("B"), + unit("C"), + unit("D"), + unit("E"), + ]), + ).toBe("A, B, C +2"); + }); +}); diff --git a/src/components/labs/__tests__/ocr-review-validation.test.ts b/src/components/labs/__tests__/ocr-review-validation.test.ts index 1052269b2..ac47ea7a9 100644 --- a/src/components/labs/__tests__/ocr-review-validation.test.ts +++ b/src/components/labs/__tests__/ocr-review-validation.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import type { OcrReviewRow } from "../ocr-review-types"; +import { seedReviewRows } from "../ocr-review-types"; import { collectRowErrors, planSave, + readingUnitDiffers, toCommitRow, validateReviewRow, } from "../ocr-review-validation"; @@ -21,6 +23,7 @@ function row(overrides: Partial = {}): OcrReviewRow { takenAt: "2026-06-10", confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing", + markerUnit: "mmol/L", duplicateOf: null, confirmed: true, ...overrides, @@ -140,3 +143,72 @@ describe("planSave", () => { } }); }); + +describe("a reading in another unit than its marker's", () => { + it.each([ + ["the same unit", { unit: "mmol/L" }, false], + ["the same unit, spelled differently", { unit: "mmol/l" }, false], + ["a different unit", { unit: "mg/dL" }, true], + ["a different magnitude of the same substance", { unit: "µmol/L" }, true], + ["a new marker (no unit yet)", { unit: "mg/dL", markerUnit: null }, false], + ["no unit stated", { unit: "", markerUnit: "mmol/L" }, false], + [ + "a qualitative result", + { value: null, valueText: "negative", unit: "mg/dL" }, + false, + ], + ] as const)("%s → differs: %s", (_name, overrides, differs) => { + expect(readingUnitDiffers(row(overrides))).toBe(differs); + }); + + it("is a blocking error on the unit field, distinct from a missing unit", () => { + expect(validateReviewRow(row({ unit: "mg/dL" }))).toEqual({ + unitMismatch: true, + }); + expect(validateReviewRow(row({ unit: "" }))).toEqual({ unit: true }); + }); + + it("cannot be turned into a payload, whatever its other fields say", () => { + expect(toCommitRow(row({ unit: "mg/dL" }))).toBeNull(); + expect(toCommitRow(row({ unit: "mmol/l" }))?.unit).toBe("mmol/l"); + }); + + it("blocks the whole save when it is selected, and not when it is not", () => { + expect( + planSave([row({ key: "a" }), row({ key: "b", unit: "mg/dL" })]), + ).toEqual({ kind: "blocked" }); + const plan = planSave([ + row({ key: "a" }), + row({ key: "b", unit: "mg/dL", confirmed: false }), + ]); + expect(plan.kind).toBe("ready"); + expect( + collectRowErrors([row({ unit: "mg/dL", confirmed: false })]).size, + ).toBe(0); + }); + + it("starts unselected on the review screen, like a duplicate, so it is a choice", () => { + const dto = (unit: string, markerUnit: string | null) => ({ + analyte: "Glucose", + value: 5.6, + valueText: null, + unit, + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-10", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "existing" as const, + markerUnit, + duplicateOf: null, + }); + const [same, other, fresh] = seedReviewRows( + [dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)], + null, + ); + expect(same.confirmed).toBe(true); + expect(other.confirmed).toBe(false); + expect(other.markerUnit).toBe("mg/dL"); + expect(fresh.confirmed).toBe(true); + }); +}); diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index 88e28812a..0079dfafd 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -18,6 +18,7 @@ const baseRow: OcrReviewRow = { takenAt: null, confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, biomarkerMatch: "existing", + markerUnit: "mmol/L", duplicateOf: null, confirmed: true, }; @@ -115,3 +116,42 @@ describe(" layout", () => { } }); }); + +describe(" unit differs from the marker's", () => { + const mismatched: OcrReviewRow = { + ...baseRow, + takenAt: "2026-06-10", + unit: "mg/dL", + markerUnit: "mmol/L", + }; + + it("says so before any save attempt, in calm wording", () => { + const html = render(undefined, "en", mismatched); + expect(html).toContain("Tracked in mmol/L; this report says mg/dL"); + expect(html).not.toContain('aria-invalid="true"'); + expect(html).not.toContain('role="alert"'); + }); + + it("marks the unit field and names the marker's unit once a save was refused", () => { + const html = render({ unitMismatch: true }, "en", mismatched); + const invalid = html.match(/]*aria-invalid="true"[^>]*>/g) ?? []; + expect(invalid).toHaveLength(1); + expect(invalid[0]).toMatch(/id="[^"]*-unit"/); + expect(html).toContain("This marker is tracked in mmol/L."); + }); + + it("shows nothing when the units agree, however they are spelled", () => { + const html = render(undefined, "en", { ...mismatched, unit: "mmol/l" }); + expect(html).not.toContain("Tracked in"); + }); + + it("shows nothing for a new marker, which adopts the row's unit", () => { + const html = render(undefined, "en", { ...mismatched, markerUnit: null }); + expect(html).not.toContain("Tracked in"); + }); + + it("speaks German too", () => { + const html = render(undefined, "de", mismatched); + expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL"); + }); +}); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index f734509a6..a11205fa7 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -19,6 +19,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ApiError } from "@/lib/api/api-fetch"; +import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr"; import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field"; import { useTranslations } from "@/lib/i18n/context"; @@ -53,6 +54,25 @@ export function handleFilePickerChange( if (file) onFilePicked(file); } +/** + * The analytes the commit skipped for a unit other than their marker's, as one + * short phrase for a toast (the first three, then a count), or null when none. + */ +export function unitSkippedAnalytes( + skipped: readonly OcrSkippedRowDto[], +): string | null { + const names = [ + ...new Set( + skipped + .filter((row) => row.reason === "unit_mismatch") + .map((row) => row.analyte), + ), + ]; + if (names.length === 0) return null; + const shown = names.slice(0, 3).join(", "); + return names.length > 3 ? `${shown} +${names.length - 3}` : shown; +} + /** * Translate an extract failure into a friendly, error-code-aware message. * Refusals are read by `meta.errorCode`: a 403 is a missing consent only when @@ -241,19 +261,32 @@ export function OcrReviewDialog({ // used to be green on any 200 and print the count regardless; the // server resolves the outcome from the two lists and the dialog // renders it. + // A row skipped for its unit is the one the person has to act on, so + // when there is one the message names it; the plain "already + // recorded" wording would be wrong for it. + const unitSkipped = unitSkippedAnalytes(result.skipped); toastWrittenOutcome( result.outcome, result.outcome === "success" ? t("labs.ocr.savedToast", { count: result.inserted.length }) : result.outcome === "partial" - ? t("labs.ocr.savedPartialToast", { - count: result.inserted.length, - skipped: result.skipped.length, - }) - : result.outcome === "failed" - ? t("labs.ocr.savedNothingToast", { + ? unitSkipped + ? t("labs.ocr.savedPartialUnitToast", { + count: result.inserted.length, + analytes: unitSkipped, + }) + : t("labs.ocr.savedPartialToast", { + count: result.inserted.length, skipped: result.skipped.length, }) + : result.outcome === "failed" + ? unitSkipped + ? t("labs.ocr.savedNothingUnitToast", { + analytes: unitSkipped, + }) + : t("labs.ocr.savedNothingToast", { + skipped: result.skipped.length, + }) : t("labs.ocr.savedEmptyToast"), ); onCommitted(); diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts index 825aa77ad..7def77782 100644 --- a/src/components/labs/ocr-review-types.ts +++ b/src/components/labs/ocr-review-types.ts @@ -7,6 +7,8 @@ */ import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr"; +import { readingUnitDiffers } from "./ocr-review-validation"; + export interface OcrReviewRow { /** Stable client key for the list (the analyte+index at seed time). */ key: string; @@ -22,6 +24,11 @@ export interface OcrReviewRow { takenAt: string | null; confidence: OcrExtractedRowDto["confidence"]; biomarkerMatch: "new" | "existing"; + /** + * The unit the matched marker is tracked in; null for a marker that does not + * exist yet. A numeric row whose `unit` differs from it cannot be saved. + */ + markerUnit: string | null; /** Non-null when the server flagged a likely duplicate of a live reading. */ duplicateOf: string | null; /** Whether the row is selected for saving. Defaults false for duplicates. */ @@ -45,8 +52,18 @@ export function seedReviewRows( takenAt: row.takenAt ?? fallbackDate, confidence: row.confidence, biomarkerMatch: row.biomarkerMatch, + markerUnit: row.markerUnit, duplicateOf: row.duplicateOf, - // A flagged duplicate starts unchecked; everything else starts confirmed. - confirmed: row.duplicateOf === null, + // A flagged duplicate starts unchecked, and so does a reading in another + // unit than its marker's (it carries a badge saying why, and ticking it + // marks the unit field until it is fixed); everything else starts + // confirmed. + confirmed: + row.duplicateOf === null && + !readingUnitDiffers({ + valueText: row.valueText, + unit: row.unit, + markerUnit: row.markerUnit, + }), })); } diff --git a/src/components/labs/ocr-review-validation.ts b/src/components/labs/ocr-review-validation.ts index 8e48e69bc..a2ab7fea2 100644 --- a/src/components/labs/ocr-review-validation.ts +++ b/src/components/labs/ocr-review-validation.ts @@ -7,10 +7,13 @@ * build the payload: it returns null exactly when `validateReviewRow` reports * at least one field. */ +import { sameLabUnit } from "@/lib/labs/unit-normalise"; + import type { OcrReviewRow } from "./ocr-review-types"; import type { OcrCommitRowInput } from "./use-ocr-extract"; -export type OcrRowField = "analyte" | "value" | "valueText" | "unit" | "date"; +export type OcrRowField = + "analyte" | "value" | "valueText" | "unit" | "unitMismatch" | "date"; /** The fields of one row that block saving; empty when the row is writable. */ export type OcrRowErrors = Partial>; @@ -26,6 +29,23 @@ function isQualitativeRow(row: OcrReviewRow): boolean { return row.valueText !== null && row.valueText !== undefined; } +/** + * Whether a numeric reading states a unit other than the one its marker is + * tracked in. A qualitative reading has no unit, a new marker adopts the row's + * own, and a row with no unit yet is a different problem (`unit`), so none of + * those count. The same rule the commit applies when it skips such a row. + */ +export function readingUnitDiffers(row: { + valueText: string | null | undefined; + unit: string | null | undefined; + markerUnit: string | null | undefined; +}): boolean { + if (row.valueText !== null && row.valueText !== undefined) return false; + const stated = (row.unit ?? "").trim(); + if (!row.markerUnit || !stated) return false; + return !sameLabUnit(stated, row.markerUnit); +} + export function validateReviewRow(row: OcrReviewRow): OcrRowErrors { const errors: OcrRowErrors = {}; if (!row.analyte.trim()) errors.analyte = true; @@ -36,6 +56,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors { } else { if (row.value === null || !Number.isFinite(row.value)) errors.value = true; if (!(row.unit ?? "").trim()) errors.unit = true; + if (readingUnitDiffers(row)) errors.unitMismatch = true; } return errors; } @@ -59,6 +80,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { if (row.value === null || !Number.isFinite(row.value)) return null; const unit = (row.unit ?? "").trim(); if (!unit) return null; + if (readingUnitDiffers(row)) return null; return { analyte, value: row.value, diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index 306e5eeb9..b103f09f7 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -32,7 +32,7 @@ import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; import type { OcrReviewRow } from "./ocr-review-types"; -import type { OcrRowErrors } from "./ocr-review-validation"; +import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation"; /** Below this per-field confidence the field is flagged for a second look. */ const CONFIDENCE_THRESHOLD = 0.6; @@ -65,10 +65,21 @@ export function OcrRowEditor({ "aria-describedby": `${fieldId}-${field}-error`, } : {}; - const fieldError = (field: keyof OcrRowErrors, messageKey: string) => + const fieldError = ( + field: keyof OcrRowErrors, + messageKey: string, + params?: Record, + ) => errors?.[field] ? ( - + ) : null; + // Stated before Save, not only after a failed attempt: a reading in another + // unit than its marker's cannot be written, and the person should see why + // the row is unticked (or will not save) while they are still reading it. + const unitDiffers = readingUnitDiffers(row); const isQualitative = row.valueText !== null && row.valueText !== undefined; const lowValueConfidence = @@ -158,6 +169,17 @@ export function OcrRowEditor({ {t("labs.ocr.duplicateWarning")} ) : null} + {unitDiffers ? ( + + + + {t("labs.ocr.unitDiffersBadge", { + markerUnit: row.markerUnit ?? "", + unit: (row.unit ?? "").trim(), + })} + + + ) : null} {lowRowConfidence ? ( @@ -224,9 +246,14 @@ export function OcrRowEditor({ id={`${fieldId}-unit`} value={row.unit ?? ""} onChange={(e) => onChange({ ...row, unit: e.target.value })} - {...invalidProps("unit")} + {...invalidProps( + errors?.unitMismatch ? "unitMismatch" : "unit", + )} /> {fieldError("unit", "labs.ocr.errorUnit")} + {fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", { + markerUnit: row.markerUnit ?? "", + })} )} diff --git a/src/lib/labs/__tests__/unit-normalise.test.ts b/src/lib/labs/__tests__/unit-normalise.test.ts new file mode 100644 index 000000000..5fb4e9d12 --- /dev/null +++ b/src/lib/labs/__tests__/unit-normalise.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { normaliseLabUnit, sameLabUnit } from "../unit-normalise"; + +describe("sameLabUnit — spellings of one unit", () => { + it.each([ + ["mmol/L", "mmol/l"], + ["mg/dL", "mg/dl"], + ["mg/dL", "mg/DL"], + ["mg/dL", " mg / dL "], + ["mg/dL", "mg/ dL"], + ["µg/L", "ug/L"], + ["µg/L", "μg/L"], + ["µg/L", "mcg/L"], + ["µmol/L", "umol/l"], + ["µIU/mL", "uIU/mL"], + ["x10^3/µL", "x10^3/uL"], + ["x10^3/µL", "x10^3/µl"], + ["mg/dL", "MG/DL"], + ["mmol/L", "MMOL/L"], + ["µmol/L", "UMOL/L"], + ["ng/mL", "NG/ML"], + ["IU/L", "IU/l"], + ["IU/L", "IU/L"], + ])("%s = %s", (a, b) => { + expect(sameLabUnit(a, b)).toBe(true); + expect(sameLabUnit(b, a)).toBe(true); + }); +}); + +describe("sameLabUnit — never merges units of different magnitude or kind", () => { + it.each([ + // The point of the module: nothing that changes the number. + ["mg/dL", "mmol/L"], + ["mg/dL", "mg/L"], + ["mg/L", "µg/L"], + ["g/dL", "g/L"], + ["ng/mL", "ng/dL"], + ["µg/L", "mg/L"], + ["%", "mmol/mol"], + ["mmol/L", "µmol/L"], + ["mmol/L", "mol/L"], + // Case that carries meaning stays significant. + ["mIU/L", "MIU/L"], + ["g/L", "G/L"], + ["u/L", "U/L"], + ["mmol/L", "Mmol/L"], + ["pmol/L", "Pmol/L"], + // A unit the module does not know compares exactly, so it cannot be guessed. + ["mg/dL", "mg/dl."], + ["mg/dL", "mg per dL"], + ["mg/dL", ""], + ])("%s ≠ %s", (a, b) => { + expect(sameLabUnit(a, b)).toBe(false); + expect(sameLabUnit(b, a)).toBe(false); + }); + + it("does not turn a unit printed in capitals into one that is not on the list", () => { + // `G/L` shouted is the blood-count unit, not grams per litre. + expect(normaliseLabUnit("G/L")).toBe("G/L"); + expect(normaliseLabUnit("MIU/L")).toBe("MIU/L"); + }); + + it("keeps enzyme units apart from the micro prefix", () => { + expect(normaliseLabUnit("u/L")).toBe("u/L"); + expect(normaliseLabUnit("U/L")).toBe("U/L"); + }); +}); + +describe("normaliseLabUnit", () => { + it("is idempotent", () => { + for (const unit of [ + "ug/l", + "MG/DL", + "mmol/l", + " x10^3 / uL ", + "mIU/L", + "G/L", + ]) { + const once = normaliseLabUnit(unit); + expect(normaliseLabUnit(once)).toBe(once); + } + }); + + it("names the canonical spelling", () => { + expect(normaliseLabUnit("mmol/l")).toBe("mmol/L"); + expect(normaliseLabUnit("ug/l")).toBe("µg/L"); + expect(normaliseLabUnit("MG/DL")).toBe("mg/dL"); + }); +}); diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index af2926a09..f217f2657 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -131,7 +131,7 @@ export class OcrExtractError extends Error { * server-computed biomarker match + duplicate id. `reportDate` fills a row's * date when the row itself carries none. */ -async function annotateRow( +export async function annotateRow( userId: string, row: ExtractedRow, reportDate: string | null, @@ -144,7 +144,7 @@ async function annotateRow( // Biomarker match — case-insensitive on the catalog identity, presence-only. const existingBiomarker = await prisma.biomarker.findFirst({ where: { userId, name: { equals: analyte, mode: "insensitive" } }, - select: { id: true }, + select: { id: true, unit: true }, }); // Duplicate check: a live lab_results row with the same analyte (fuzzy, @@ -198,6 +198,7 @@ async function annotateRow( takenAt, confidence: row.confidence, biomarkerMatch: existingBiomarker ? "existing" : "new", + markerUnit: existingBiomarker?.unit ?? null, duplicateOf, }; } diff --git a/src/lib/labs/unit-normalise.ts b/src/lib/labs/unit-normalise.ts new file mode 100644 index 000000000..b67cc3346 --- /dev/null +++ b/src/lib/labs/unit-normalise.ts @@ -0,0 +1,98 @@ +/** + * Spelling-level comparison of lab units. + * + * A reading may only be stored under a marker whose unit it actually is in, so + * the write paths compare the unit a reading arrives with against the unit the + * marker is tracked in. This module answers one narrow question: are these two + * strings the same unit written two ways? + * + * It resolves spelling, never magnitude. Two spellings are treated as equal + * only where no reading of either could mean a different quantity: + * + * - whitespace around the slash (`mg / dL`), + * - the micro prefix (`µg/L`, `μg/L` with the Greek mu, `ug/L`, `mcg/L`), + * - the case of the litre in the denominator (`mmol/l`, `mg/dl`, `mg/DL`), + * - a unit printed entirely in capitals (`MG/DL`, `MMOL/L`), but only for the + * short list of units where capitals cannot denote another real unit. + * + * Case is otherwise significant, on purpose. `mIU/L` and `MIU/L` differ by a + * factor of a billion (a lone capital M is mega, a lowercase m is milli), and + * `g/L` (grams) is not `G/L` (the 10^9/L of a blood count). A unit this module + * does not recognise is compared exactly, so an unfamiliar spelling surfaces as + * a mismatch rather than being guessed at. + * + * Pure and dependency-free. + */ + +/** Units that may be recognised when printed entirely in capitals. */ +const SHOUTED_UNITS: ReadonlyMap = new Map( + [ + "mg/dL", + "g/dL", + "ng/dL", + "µg/dL", + "mmol/L", + "µmol/L", + "nmol/L", + "pmol/L", + "ng/mL", + "pg/mL", + "µg/L", + "mEq/L", + "IU/L", + "U/L", + "fL", + ].map((unit) => [unit.toLowerCase(), unit]), +); + +const MICRO_SIGNS = /[µμ]/g; +/** What may follow a spelled-out micro prefix: g, mol, IU, U, L, kat, Eq. */ +const MICRO_BEFORE = "(?=g|mol|IU|U|L|l|kat|Eq|eq)"; +const MICRO_BEFORE_LOWER = "(?=g|mol|iu|u|l|kat|eq)"; + +function foldSpacing(raw: string): string { + return raw + .trim() + .replace(/\s+/g, " ") + .replace(/\s*\/\s*/g, "/"); +} + +function foldMicro(text: string, lookahead: string): string { + const unified = text.replace(MICRO_SIGNS, "µ"); + // A spelled-out prefix counts only at the start of the unit or straight after + // a slash, and only in front of a unit that takes it. `u/L` (enzyme units) + // and `U/L` therefore stay as they are. + const prefix = new RegExp(`(^|/)(?:u|mc)${lookahead}`, "g"); + return unified.replace(prefix, "$1µ"); +} + +/** `/l`, `/dl`, `/ml`, `/µl` in any case → `/L`, `/dL`, `/mL`, `/µL`. */ +function foldLitre(text: string): string { + return text.replace( + /\/([dmµ]?)l$/i, + (_, prefix: string) => `/${prefix.toLowerCase()}L`, + ); +} + +/** + * The comparison form of a lab unit: the same string for every spelling that + * means the same thing, and the (trimmed) string itself otherwise. + */ +export function normaliseLabUnit(raw: string): string { + const spaced = foldSpacing(raw); + + const hasLowercase = /[a-zµμ]/.test(spaced); + const hasUppercase = /[A-Z]/.test(spaced); + if (hasUppercase && !hasLowercase) { + const lowered = foldMicro(spaced.toLowerCase(), MICRO_BEFORE_LOWER); + const known = SHOUTED_UNITS.get(lowered); + if (known) return known; + } + + return foldLitre(foldMicro(spaced, MICRO_BEFORE)); +} + +/** Whether two strings name the same lab unit (see the module comment). */ +export function sameLabUnit(a: string, b: string): boolean { + return normaliseLabUnit(a) === normaliseLabUnit(b); +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 8932c74da..79376a1e6 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -213,6 +213,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "labs.ocr.rateLimited", "labs.restore.invalid", "labs.result.notFound", + "labs.unit.mismatch", "labs.update.invalid", "labs.update.linkedFieldsImmutable", "labs.update.numericExpected", diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 81dd942af..ca0b92046 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -162,6 +162,11 @@ 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.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...recordWriteRateLimitResponse, }, }, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index f894cf8aa..9a816fcd0 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -72,6 +72,10 @@ const extractedRow = z takenAt: z.string().nullable(), confidence: extractConfidence, biomarkerMatch: z.enum(["new", "existing"]), + markerUnit: z.string().nullable().meta({ + description: + "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save.", + }), duplicateOf: z.string().nullable(), }) .meta({ @@ -138,7 +142,10 @@ const commitResponse = z skipped: z.array( z.object({ analyte: z.string(), - reason: z.literal("duplicate"), + 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.", + }), }), ), outcome: z.enum(["empty", "failed", "partial", "success"]).meta({ @@ -149,7 +156,7 @@ const commitResponse = z .meta({ id: "OcrCommitResponse", description: - 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.', + 'The rows written (`source: "OCR"`), those skipped (commit-time duplicates, or a unit that differs from that of the marker), and the resolved outcome.', }); export const ocrPaths: NonNullable = { @@ -223,7 +230,7 @@ export const ocrPaths: NonNullable = { tags: ["Labs"], summary: "Commit confirmed Lab-OCR rows", description: - 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped. Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.', + 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading, or states a unit other than that of its marker, is skipped (`skipped[].reason`). Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.', requestBody: { required: true, content: { "application/json": { schema: ocrCommitRequest } }, diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 000a78ab5..59c076f61 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -125,6 +125,12 @@ export interface OcrExtractedRowDto { range: number; }; biomarkerMatch: "new" | "existing"; + /** + * The unit the matched marker is tracked in, or null for a marker that does + * not exist yet (it will adopt this row's unit). Lets the review screen say + * so before Save when the scanned unit differs. + */ + markerUnit: string | null; duplicateOf: string | null; } @@ -231,10 +237,14 @@ export const ocrCommitSchema = z.object({ export type OcrCommitInput = z.infer; -/** A row the commit route skipped (a re-checked duplicate at commit time). */ +/** + * A row the commit route did not write. `duplicate`: it matched a live reading + * at commit time. `unit_mismatch`: it states a unit other than the one its + * marker is tracked in. + */ export interface OcrSkippedRowDto { analyte: string; - reason: "duplicate"; + reason: "duplicate" | "unit_mismatch"; } export interface OcrCommitResponseDto { diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 3b0e4510e..2ae7458d8 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -126,6 +126,12 @@ vi.mock("@/lib/cache/invalidate", () => ({ const OWNER_ID = "round-trip-owner"; const AT = (iso: string) => new Date(iso); +// The restore drops a measurement tombstone older than +// `TOMBSTONE_RETENTION_DAYS` on purpose, so the fixture's deletion has to stay +// recent on whatever day the suite runs. A fixed date aged out on 2026-10-03. +const TOMBSTONE_DELETED_AT = new Date( + Math.floor((Date.now() - 10 * 86_400_000) / 1000) * 1000, +).toISOString(); const PHQ9_RESPONSES = JSON.stringify({ items: [2, 1, 2, 2, 1, 1, 2, 1, 2], schema: 1, @@ -351,7 +357,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { source: "APPLE_HEALTH", externalId: "round-trip-deleted-sample", syncVersion: 4, - deletedAt: AT("2026-07-20T09:15:00.000Z"), + deletedAt: AT(TOMBSTONE_DELETED_AT), }, }); // The EVENT row an ECG strip is filed against, and the strip itself. The @@ -1425,7 +1431,7 @@ describe("every model the plan claims two-ended survives a real restore", () => ), "a soft-deleted reading is missing from the disaster-recovery payload", ).toMatchObject({ - deletedAt: "2026-07-20T09:15:00.000Z", + deletedAt: TOMBSTONE_DELETED_AT, syncVersion: 4, }); @@ -2342,7 +2348,7 @@ describe("every model the plan claims two-ended survives a real restore", () => }, "the deleted reading came back, but not as a deletion — a tombstone " + "restored without its `deletedAt` is a resurrected measurement", - ).toEqual({ deletedAt: "2026-07-20T09:15:00.000Z", syncVersion: 4 }); + ).toEqual({ deletedAt: TOMBSTONE_DELETED_AT, syncVersion: 4 }); const restoredMeasurement = await prisma.measurement.findFirstOrThrow({ where: { userId: OWNER_ID, type: "WEIGHT" }, diff --git a/tests/integration/lab-unit-mismatch.test.ts b/tests/integration/lab-unit-mismatch.test.ts new file mode 100644 index 000000000..0596330b7 --- /dev/null +++ b/tests/integration/lab-unit-mismatch.test.ts @@ -0,0 +1,283 @@ +/** + * A reading may only be stored under the unit it is actually in. + * + * Before this, a reading that stated another unit than its marker's (5.6 + * mmol/L against a marker kept in mg/dL) was written with its value unchanged + * and the marker's unit stamped on it, and the range check then judged 5.6 + * against a mg/dL band. These tests follow that reading through the real + * routes and the real database, and assert both what is refused and what is + * 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. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-lab-unit-mismatch"; + +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), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: USER_ID, + username: "lab-unit-mismatch", + email: "lab-unit-mismatch@example.test", + }, + }); + const session = await prisma.session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +const TAKEN_AT = "2026-07-10T12:00:00.000Z"; + +async function seedGlucose() { + return getPrismaClient().biomarker.create({ + data: { + userId: USER_ID, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 99, + }, + }); +} + +function jsonReq(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function postLab(body: Record) { + const { POST } = await import("@/app/api/labs/route"); + const res = await POST(jsonReq("/api/labs", { takenAt: TAKEN_AT, ...body })); + return { status: res.status, body: await res.json() }; +} + +async function commitRows(rows: Record[]) { + const { POST } = await import("@/app/api/labs/ocr/commit/route"); + const res = await POST( + jsonReq("/api/labs/ocr/commit", { + rows: rows.map((row) => ({ takenAt: TAKEN_AT, ...row })), + }), + ); + return { status: res.status, body: await res.json() }; +} + +const labCount = () => + getPrismaClient().labResult.count({ where: { userId: USER_ID } }); + +describe("POST /api/labs", () => { + it("refuses a reading in another unit than its marker's and writes nothing", async () => { + await seedGlucose(); + const { status, body } = await postLab({ + analyte: "Glucose", + value: 5.6, + unit: "mmol/L", + }); + expect(status).toBe(422); + expect(body.meta).toMatchObject({ + errorCode: "labs.unit.mismatch", + markerUnit: "mg/dL", + readingUnit: "mmol/L", + }); + expect(await labCount()).toBe(0); + }); + + it("refuses it on the structured path too, when a unit is sent", async () => { + const marker = await seedGlucose(); + const { status, body } = await postLab({ + biomarkerId: marker.id, + value: 5.6, + unit: "mmol/L", + }); + expect(status).toBe(422); + expect(body.meta.errorCode).toBe("labs.unit.mismatch"); + expect(await labCount()).toBe(0); + }); + + it("writes a reading on the structured path that sends no unit, under the marker's", async () => { + const marker = await seedGlucose(); + const { status, body } = await postLab({ + biomarkerId: marker.id, + value: 95, + }); + expect(status).toBe(201); + expect(body.data).toMatchObject({ value: 95, unit: "mg/dL" }); + }); + + it.each(["mg/dl", "mg/DL", "MG/DL", " mg / dL "])( + "writes the same unit spelled %j", + async (spelling) => { + await seedGlucose(); + const { status, body } = await postLab({ + analyte: "Glucose", + value: 95, + unit: spelling, + }); + expect(status).toBe(201); + expect(body.data).toMatchObject({ value: 95, unit: "mg/dL" }); + }, + ); + + it("does not compare a qualitative reading, which has no unit", async () => { + await seedGlucose(); + const { status } = await postLab({ + analyte: "Glucose", + valueText: "negative", + unit: "mmol/L", + }); + expect(status).toBe(201); + }); + + it("lets a new marker adopt the unit of its first reading", async () => { + const { status, body } = await postLab({ + analyte: "Creatinine", + value: 80, + unit: "µmol/L", + }); + expect(status).toBe(201); + expect(body.data.unit).toBe("µmol/L"); + // …and then holds the next reading in another unit to it. + const next = await postLab({ + analyte: "Creatinine", + value: 0.9, + unit: "mg/dL", + }); + 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" }); + const { GET } = await import("@/app/api/labs/route"); + const res = await GET( + new NextRequest("http://localhost/api/labs?analyte=Glucose"), + ); + expect((await res.json()).data.results).toEqual([]); + }); +}); + +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(); + const { status, body } = await commitRows([ + { analyte: "Glucose", value: 95, unit: "mg/dL" }, + { + analyte: "Glucose", + value: 5.6, + unit: "mmol/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.skipped).toEqual([ + { analyte: "Glucose", 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(); + const { body } = await commitRows([ + { analyte: "Glucose", value: 5.6, unit: "mmol/L" }, + ]); + expect(body.data.inserted).toHaveLength(0); + expect(body.data.outcome).toBe("failed"); + expect(await labCount()).toBe(0); + }); + + it("accepts a different spelling of the marker's unit", async () => { + await seedGlucose(); + const { body } = await commitRows([ + { analyte: "Glucose", value: 95, unit: "MG/DL" }, + ]); + expect(body.data.outcome).toBe("success"); + expect(body.data.inserted[0].unit).toBe("mg/dL"); + }); + + it("does not compare a qualitative row", async () => { + await seedGlucose(); + const { body } = await commitRows([ + { analyte: "Glucose", valueText: "negative", unit: "mmol/L" }, + ]); + expect(body.data.outcome).toBe("success"); + }); +}); + +describe("the scan review's view of a row", () => { + const scanned = (analyte: string, unit: string) => ({ + analyte, + value: 5.6, + valueText: null, + unit, + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: null, + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + }); + + it("carries the matched marker's unit, so the screen can say so before Save", async () => { + await seedGlucose(); + const { annotateRow } = await import("@/lib/labs/ocr-extract"); + const known = await annotateRow( + USER_ID, + scanned("Glucose", "mmol/L"), + "2026-07-10", + "UTC", + ); + expect(known).toMatchObject({ + biomarkerMatch: "existing", + markerUnit: "mg/dL", + }); + + const fresh = await annotateRow( + USER_ID, + scanned("Creatinine", "µmol/L"), + "2026-07-10", + "UTC", + ); + expect(fresh).toMatchObject({ biomarkerMatch: "new", markerUnit: null }); + }); +});