From 07f09e1b610cae07bd3ba7a9dbbafa06c8ab21f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:18:49 +0200 Subject: [PATCH] fix(labs): store the catalog slug as a marker's analyte key The matcher stored the registry entry's key, so LDL and HDL markers were recorded as total-cholesterol. The key now names what the marker is (the catalog slug, or a registry-only key for an analyte with no seed), and the registry resolves ldl and hdl to the cholesterol entry for the conversion as before. A key already written in the old form is rewritten by the same fill step that brings a stale key in line with its name, the first time a reading needs it. The OpenAPI descriptions say what the key is and that it follows the marker's name and unit. --- docs/api/openapi.yaml | 20 +++++---- src/components/labs/ocr-review-types.ts | 2 +- src/components/labs/types.ts | 2 +- src/lib/labs/__tests__/analyte-match.test.ts | 19 ++++++--- src/lib/labs/analyte-match.ts | 22 +++++----- src/lib/labs/biomarker-catalog.ts | 6 ++- src/lib/labs/serialise.ts | 2 +- src/lib/openapi/routes/biomarkers.ts | 2 +- src/lib/openapi/routes/ocr.ts | 2 +- src/lib/validations/labs-ocr.ts | 2 +- tests/integration/labs-analyte-key.test.ts | 45 ++++++++++++++++++++ 11 files changed, 91 insertions(+), 33 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c45800cf0..27d693a21 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -68258,9 +68258,9 @@ components: anyOf: - type: string - type: "null" - description: The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null - when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a - row in another unit can be converted on commit. + description: The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only + key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` + it tells a client whether a row in another unit can be converted on commit. duplicateOf: anyOf: - type: string @@ -68439,12 +68439,14 @@ components: - createdAt - updatedAt additionalProperties: false - description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. - `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker - that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting - it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds - from here. + description: "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its + presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. + `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed + (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never + chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit + is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the + lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker + resolves its unit + reference bounds from here." CreateBiomarkerResponse: type: object properties: diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts index c05b394b2..9a167ff2f 100644 --- a/src/components/labs/ocr-review-types.ts +++ b/src/components/labs/ocr-review-types.ts @@ -30,7 +30,7 @@ export interface OcrReviewRow { * Save when it can be (see `readingConversion`) and cannot be saved when not. */ markerUnit: string | null; - /** Which analyte the matched marker is (conversion registry key), or null. */ + /** Which analyte the matched marker is (catalog slug or registry-only key), or null. */ markerAnalyteKey: string | null; /** Non-null when the server flagged a likely duplicate of a live reading. */ duplicateOf: string | null; diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 1fbfc6f44..90e7f60fb 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -75,7 +75,7 @@ export interface BiomarkerDto { panel: string | null; /** * Which analyte the marker is, for converting a reading in another unit - * (the unit-conversion registry's key), or null when it is none that is known. + * (the catalog slug, e.g. `ldl`, or a registry-only key such as `urea`), or null when it is none that is known. */ analyteKey: string | null; hasContext: boolean; diff --git a/src/lib/labs/__tests__/analyte-match.test.ts b/src/lib/labs/__tests__/analyte-match.test.ts index 28cc86302..56668108d 100644 --- a/src/lib/labs/__tests__/analyte-match.test.ts +++ b/src/lib/labs/__tests__/analyte-match.test.ts @@ -26,15 +26,22 @@ describe("the catalog's own names, in every language", () => { ["Gesamtcholesterin", "mg/dL", "total-cholesterol"], ["Cholestérol total", "mmol/L", "total-cholesterol"], ["LDL cholesterol", "mg/dL", "ldl"], - ["LDL-Cholesterin", "mg/dL", "total-cholesterol"], - ["Colesterolo HDL", "mg/dL", "total-cholesterol"], + ["LDL-Cholesterin", "mg/dL", "ldl"], + ["Colesterolo HDL", "mg/dL", "hdl"], ["Vitamin D (25-OH)", "ng/mL", "vitamin-d"], ["Créatinine", "mg/dL", "creatinine"], ["Kreatynina", "µmol/L", "creatinine"], ])("%s (%s) is %s", (name, unit, key) => { - // LDL and HDL are aliases of cholesterol: the registry reports one key. - const expected = analyteConversionFor(key)!.key; - expect(match(name, unit)).toBe(expected); + // The key is the catalog slug the name belongs to, so LDL and HDL keep + // their own identity even though they convert with the cholesterol entry. + expect(match(name, unit)).toBe(key); + }); + + it("resolves an LDL or HDL key to the cholesterol entry for the conversion", () => { + expect(match("LDL cholesterol", "mg/dL")).toBe("ldl"); + expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol"); + expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol"); + expect(analyteAcceptsUnit("ldl", "mmol/L")).toBe(true); }); it("recognises a seed typed without the app's gloss", () => { @@ -65,7 +72,7 @@ describe("typed names", () => { it("folds case, accents, hyphens and spacing the same on both sides", () => { expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid"); - expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key); + expect(match("ldl-c", "mg/dL")).toBe("ldl"); expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); }); }); diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts index 0e04d48f3..2e7338ad4 100644 --- a/src/lib/labs/analyte-match.ts +++ b/src/lib/labs/analyte-match.ts @@ -12,8 +12,12 @@ * - the marker's own unit must be one the analyte reaches, so a marker called * "Glucose" in a unit the registry cannot convert is left unlinked. * - * Returns the registry key, or null. Pure apart from reading the message - * bundles once. + * Returns the catalog slug the name was recognised as (`ldl`, `hdl`, + * `fasting-glucose`), or the registry-only key for an analyte with no seed + * (`urea`, `uric-acid`), or null. LDL stays `ldl` even though the registry + * converts it with the cholesterol entry: the key says which marker this is, + * and `analyteConversionFor` resolves it to its entry. Pure apart from reading + * the message bundles once. */ import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { localisedValues } from "@/lib/i18n/shared-resolve"; @@ -30,7 +34,7 @@ function withoutGloss(folded: string): string { let cache: ReadonlyMap | null = null; /** - * folded name -> registry key, with every name two analytes claim removed. + * folded name -> analyte key, with every name two keys claim removed. * Exported for the guard test, which asserts nothing was dropped silently. */ export function analyteNameIndex(): { @@ -47,18 +51,16 @@ export function analyteNameIndex(): { }; for (const seed of BIOMARKER_CATALOG) { - const entry = analyteConversionFor(seed.slug); - if (!entry) continue; + if (!analyteConversionFor(seed.slug)) continue; for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { - claim(name, entry.key); + claim(name, seed.slug); // The bare name too, for a marker typed without the app's gloss. - claim(withoutGloss(foldForMatch(name)), entry.key); + claim(withoutGloss(foldForMatch(name)), seed.slug); } } for (const { key, names } of ANALYTE_ALIASES) { - const entry = analyteConversionFor(key); - if (!entry) continue; - for (const name of names) claim(name, entry.key); + if (!analyteConversionFor(key)) continue; + for (const name of names) claim(name, key); } const index = new Map(); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 3bcf120af..376a63b6a 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -279,7 +279,9 @@ export const BIOMARKER_PANELS = [ /** * Names a marker may carry for an analyte the unit-conversion registry knows - * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the registry's key. + * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the analyte key a + * marker stores: the catalog slug (`ldl`, `hdl`, `fasting-glucose`), or a + * registry-only key for an analyte with no seed. * * A `Biomarker` row keeps only its name, not the seed it came from, so this is * how a marker is told which analyte it is: its folded name equals one of these, @@ -296,7 +298,7 @@ export const BIOMARKER_PANELS = [ * names live only here. */ export interface AnalyteAliases { - /** The registry key of the analyte. */ + /** The analyte key: a catalog slug, or a registry-only key. */ key: string; names: readonly string[]; } diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts index aa68ed09a..20d93ed7d 100644 --- a/src/lib/labs/serialise.ts +++ b/src/lib/labs/serialise.ts @@ -57,7 +57,7 @@ export interface ResolvedBiomarker { lowerBound: number | null; upperBound: number | null; panel: string | null; - /** Which analyte the marker is (unit-conversion registry key), if known. */ + /** Which analyte the marker is (catalog slug or registry-only key), if known. */ analyteKey?: string | null; } diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 6ea98d8c8..9b8fabf2b 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -63,7 +63,7 @@ const biomarkerRow = z .meta({ id: "Biomarker", description: - "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", + "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); const listResponse = z diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 74d8e4abd..493078b4f 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -79,7 +79,7 @@ const extractedRow = z }), markerAnalyteKey: z.string().nullable().meta({ description: - "The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", + "The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", }), duplicateOf: z.string().nullable(), }) diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index d19bb0f4e..8e11aa7d9 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -132,7 +132,7 @@ export interface OcrExtractedRowDto { */ markerUnit: string | null; /** - * Which analyte the matched marker is (unit-conversion registry key), or null + * Which analyte the matched marker is (catalog slug or registry-only key), or null * when the marker is new or none is known. With `markerUnit` it tells the * review screen whether a row in another unit can be converted on Save. */ diff --git a/tests/integration/labs-analyte-key.test.ts b/tests/integration/labs-analyte-key.test.ts index 595c70261..0e17970ff 100644 --- a/tests/integration/labs-analyte-key.test.ts +++ b/tests/integration/labs-analyte-key.test.ts @@ -88,6 +88,33 @@ describe("POST /api/biomarkers", () => { expect(data.analyteKey).toBe("total-cholesterol"); }); + it("stores the catalog slug for LDL and HDL, and converts them as cholesterol", async () => { + for (const [name, key] of [ + ["LDL cholesterol", "ldl"], + ["HDL-Cholesterin", "hdl"], + ]) { + const res = await post({ name, unit: "mg/dL" }); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey, name).toBe(key); + } + const ldl = await prisma.biomarker.findFirstOrThrow({ + where: { userId: USER, name: "LDL cholesterol" }, + }); + const reading = await postReading({ + biomarkerId: ldl.id, + value: 3, + unit: "mmol/L", + takenAt: "2026-05-04T08:00:00.000Z", + }); + expect(reading.status).toBe(201); + const { data: dto } = (await reading.json()) as { + data: { value: number; converted: boolean }; + }; + expect(dto.converted).toBe(true); + // 3 mmol/L x 38.6664 (C27H46O, 386.664 g/mol). + expect(dto.value.toFixed(2)).toBe("116.00"); + }); + it("leaves a free name, and a known name in an unreachable unit, unlinked", async () => { for (const [name, unit] of [ ["My own marker", "mg/dL"], @@ -277,6 +304,24 @@ describe("ensureAnalyteKey", () => { ).toBe("urea"); }); + it("corrects a key stored as the registry entry rather than the catalog slug", async () => { + // Before the slug was stored, an LDL marker got the cholesterol entry's + // key. Same conversion, wrong identity; the fill step rewrites it once. + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "LDL-Cholesterin", + unit: "mg/dL", + analyteKey: "total-cholesterol", + }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBe("ldl"); + const row = await prisma.biomarker.findUnique({ where: { id: marker.id } }); + expect(row?.analyteKey).toBe("ldl"); + // And a second run changes nothing. + expect(await ensureAnalyteKey(prisma, row!)).toBe("ldl"); + }); + it("converges when two callers fill the same marker", async () => { const marker = await prisma.biomarker.create({ data: { userId: USER, name: "Kreatinin", unit: "mg/dL" },