mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/main' into feat/document-ai-runs
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
20470d99f4
28 files changed
+891
-38
No files matched your search
@@ -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
|
||||
+38
-15
@@ -14063,7 +14063,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:
|
||||
@@ -14283,9 +14292,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:
|
||||
@@ -31825,15 +31834,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,
|
||||
@@ -63397,7 +63407,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
|
||||
@@ -63417,7 +63431,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:
|
||||
@@ -63746,6 +63761,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
|
||||
@@ -63761,6 +63783,7 @@ components:
|
||||
- takenAt
|
||||
- confidence
|
||||
- biomarkerMatch
|
||||
- markerUnit
|
||||
- duplicateOf
|
||||
additionalProperties: false
|
||||
description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side:
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -9063,6 +9063,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.",
|
||||
|
||||
@@ -9063,6 +9063,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.",
|
||||
|
||||
@@ -9063,6 +9063,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.",
|
||||
|
||||
@@ -9063,6 +9063,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.",
|
||||
|
||||
@@ -9063,6 +9063,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.",
|
||||
|
||||
@@ -9063,6 +9063,10 @@
|
||||
"errorValue": "값을 입력하세요.",
|
||||
"errorResult": "결과를 입력하세요.",
|
||||
"errorUnit": "단위를 입력하세요.",
|
||||
"errorUnitMismatch": "이 지표는 {markerUnit} 단위로 기록됩니다. 결과지를 잘못 읽었다면 단위를 바꾸거나 이 값의 선택을 해제하세요.",
|
||||
"unitDiffersBadge": "{markerUnit} 단위로 기록 중; 이 결과지는 {unit}",
|
||||
"savedPartialUnitToast": "검사 결과 {count}개를 저장했습니다. 단위가 지표와 달라 저장하지 않은 항목: {analytes}",
|
||||
"savedNothingUnitToast": "저장된 항목이 없습니다: {analytes}의 단위가 지표와 다릅니다",
|
||||
"saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"providerUnsupported": "AI 제공자가 이미지를 읽지 못해요. 이미지를 읽는 모델로 설정해 주세요.",
|
||||
"consentRequired": "결과지를 스캔하려면 AI 처리 동의가 필요해요.",
|
||||
|
||||
@@ -9063,6 +9063,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.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
handleFilePickerChange,
|
||||
OcrPageCoverageNote,
|
||||
OcrReviewDialog,
|
||||
unitSkippedAnalytes,
|
||||
} from "../ocr-review-dialog";
|
||||
|
||||
const hookState = vi.hoisted(() => ({ extractPending: false }));
|
||||
@@ -223,3 +224,40 @@ describe("<OcrPageCoverageNote> — 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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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("<OcrRowEditor> layout", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OcrRowEditor> 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(/<input[^>]*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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -245,19 +265,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();
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -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<Record<OcrRowField, true>>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>,
|
||||
) =>
|
||||
errors?.[field] ? (
|
||||
<FieldError id={`${fieldId}-${field}-error`} message={t(messageKey)} />
|
||||
<FieldError
|
||||
id={`${fieldId}-${field}-error`}
|
||||
message={t(messageKey, params)}
|
||||
/>
|
||||
) : 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")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{unitDiffers ? (
|
||||
<Badge variant="secondary" className="max-w-full">
|
||||
<Ruler aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.unitDiffersBadge", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
unit: (row.unit ?? "").trim(),
|
||||
})}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{lowRowConfidence ? (
|
||||
<Badge variant="secondary" className="text-muted-foreground">
|
||||
<AlertCircle aria-hidden />
|
||||
@@ -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 ?? "",
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string> = 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);
|
||||
}
|
||||
@@ -218,6 +218,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"labs.ocr.rateLimited",
|
||||
"labs.restore.invalid",
|
||||
"labs.result.notFound",
|
||||
"labs.unit.mismatch",
|
||||
"labs.update.invalid",
|
||||
"labs.update.linkedFieldsImmutable",
|
||||
"labs.update.numericExpected",
|
||||
|
||||
@@ -162,6 +162,11 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -73,6 +73,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({
|
||||
@@ -139,7 +143,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({
|
||||
@@ -150,7 +157,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<ZodOpenApiObject["paths"]> = {
|
||||
@@ -217,7 +224,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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 } },
|
||||
|
||||
@@ -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<typeof ocrCommitSchema>;
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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" },
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>[]) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user