diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1754488a8..5868d4c1b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -68333,6 +68333,10 @@ components: anyOf: - type: string - type: "null" + analyteKey: + anyOf: + - type: string + - type: "null" hasContext: type: boolean context: @@ -68352,6 +68356,7 @@ components: - lowerBound - upperBound - panel + - analyteKey - hasContext - context - hidden @@ -68359,8 +68364,10 @@ components: - updatedAt additionalProperties: false description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. `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 + 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. CreateBiomarkerResponse: type: object diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index a17d0b015..d3aec468b 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -41,6 +41,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; @@ -53,6 +54,7 @@ function serialiseBiomarker(row: { lowerBound: row.lowerBound, upperBound: row.upperBound, panel: row.panel, + analyteKey: row.analyteKey, hasContext: row.contextEncrypted !== null, context: row.contextEncrypted ? decryptContextFromBytes(row.contextEncrypted) diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 8de993f70..8736ab76d 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -17,6 +17,7 @@ import { decryptContextSoft, encryptContextToBytes, } from "@/lib/labs/biomarker-store"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; @@ -51,6 +52,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + analyteKey: string | null; hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; @@ -63,6 +65,7 @@ function serialiseBiomarker(row: { lowerBound: row.lowerBound, upperBound: row.upperBound, panel: row.panel, + analyteKey: row.analyteKey, hasContext: row.contextEncrypted !== null, context: decryptContextSoft(row.contextEncrypted), hidden: row.hidden, @@ -158,6 +161,9 @@ async function postBiomarker(request: NextRequest) { lowerBound: lowerBound ?? null, upperBound: upperBound ?? null, panel: panel ?? null, + // Which analyte it is, for the unit conversion; null when the name is no + // analyte the registry knows. + analyteKey: matchAnalyteKey({ name, unit }), contextEncrypted: context ? encryptContextToBytes(context) : null, }, }); diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 2f7eae4a4..b72b4d0df 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -60,6 +60,11 @@ export interface BiomarkerDto { lowerBound: number | null; upperBound: number | null; 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. + */ + analyteKey: string | null; hasContext: boolean; context: string | null; /** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */ diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts index 61ffd162f..8481adae3 100644 --- a/src/lib/jobs/lab-biomarker-backfill.ts +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill"; @@ -161,6 +162,7 @@ export async function runLabBiomarkerBackfillForUser( lowerBound: group.referenceLow, upperBound: group.referenceHigh, panel: group.panel, + analyteKey: matchAnalyteKey({ name: group.name, unit: group.unit }), }, }); markers += 1; diff --git a/src/lib/labs/__tests__/analyte-match.test.ts b/src/lib/labs/__tests__/analyte-match.test.ts new file mode 100644 index 000000000..28cc86302 --- /dev/null +++ b/src/lib/labs/__tests__/analyte-match.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +import { matchAnalyteKey, analyteNameIndex } from "../analyte-match"; +import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog"; +import { analyteAcceptsUnit } from "../unit-conversion/convert"; +import { + allAnalyteConversions, + analyteConversionFor, +} from "../unit-conversion/registry"; + +const match = (name: string, unit: string) => matchAnalyteKey({ name, unit }); + +describe("the catalog's own names, in every language", () => { + it.each([ + ["Fasting glucose", "mg/dL", "fasting-glucose"], + ["Nüchternglukose", "mg/dL", "fasting-glucose"], + ["Glucosa en ayunas", "mg/dL", "fasting-glucose"], + ["Glycémie à jeun", "mmol/L", "fasting-glucose"], + ["Glicemia a digiuno", "mg/dL", "fasting-glucose"], + ["공복혈당", "mg/dL", "fasting-glucose"], + ["Glukoza na czczo", "mg/dL", "fasting-glucose"], + ["HbA1c", "%", "hba1c"], + ["Total cholesterol", "mg/dL", "total-cholesterol"], + ["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"], + ["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); + }); + + it("recognises a seed typed without the app's gloss", () => { + expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d"); + }); +}); + +describe("typed names", () => { + it.each([ + ["Glucose", "mg/dL", "fasting-glucose"], + ["Blutzucker", "mg/dL", "fasting-glucose"], + ["blood sugar", "mmol/L", "fasting-glucose"], + ["Cholesterin", "mg/dL", "total-cholesterol"], + ["Serum Creatinine", "mg/dL", "creatinine"], + ["Urea", "mmol/L", "urea"], + ["Harnstoff", "mg/dL", "urea"], + ["BUN", "mg/dL", "blood-urea-nitrogen"], + ["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"], + ["Harnsäure", "µmol/L", "uric-acid"], + ["Uric acid", "mg/dL", "uric-acid"], + ["Kalzium", "mmol/L", "calcium-total"], + ["Calcium", "mg/dL", "calcium-total"], + ["Gesamtbilirubin", "µmol/L", "bilirubin-total"], + ["Bilirubin total", "mg/dL", "bilirubin-total"], + ])("%s (%s) is %s", (name, unit, key) => { + expect(match(name, unit)).toBe(key); + }); + + 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("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); + }); +}); + +describe("a name that is not the whole analyte name stays unlinked", () => { + it.each([ + ["Calcium (ionisiert)", "mmol/L"], + ["Ionised calcium", "mmol/L"], + ["Calcium ionisiert", "mmol/L"], + ["Urea nitrogen ratio", "mg/dL"], + ["Glucose (urine)", "mg/dL"], + ["Glucose tolerance", "mg/dL"], + ["Non-HDL cholesterol", "mg/dL"], + ["Cholesterol ratio", "mg/dL"], + ["Direct bilirubin", "mg/dL"], + ["Free thyroxine", "pmol/L"], + ["", "mg/dL"], + ])("%s", (name, unit) => { + expect(match(name, unit)).toBeNull(); + }); + + it("keeps urea and blood urea nitrogen apart", () => { + expect(match("Urea", "mmol/L")).toBe("urea"); + expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen"); + }); +}); + +describe("the marker's unit must be one the analyte reaches", () => { + it("links a unit the entry lists and one that differs by a power of ten", () => { + expect(match("Glucose", "mg/dL")).toBe("fasting-glucose"); + expect(match("Glucose", "mmol/L")).toBe("fasting-glucose"); + expect(match("Glucose", "g/L")).toBe("fasting-glucose"); + expect(match("Creatinine", "mmol/L")).toBe("creatinine"); + }); + + it("leaves a unit the registry cannot reach unlinked", () => { + expect(match("Glucose", "mEq/L")).toBeNull(); + expect(match("Glucose", "%")).toBeNull(); + expect(match("HbA1c", "mg/dL")).toBeNull(); + expect(match("Calcium", "furlongs")).toBeNull(); + }); + + it("reads a spelling of the unit through the normaliser", () => { + expect(match("Glucose", "MG/DL")).toBe("fasting-glucose"); + expect(match("Creatinine", "umol/l")).toBe("creatinine"); + }); +}); + +describe("the alias list, as a guard", () => { + it("names only analytes the registry knows", () => { + for (const { key } of ANALYTE_ALIASES) { + expect(analyteConversionFor(key), key).not.toBeNull(); + } + }); + + it("writes every name already folded, so what is read is what is matched", () => { + for (const { key, names } of ANALYTE_ALIASES) { + for (const name of names) { + expect(foldForMatch(name), `${key}: ${name}`).toBe(name); + } + } + }); + + it("has no name that two analytes claim", () => { + expect(analyteNameIndex().ambiguous).toEqual([]); + }); + + it("gives every registry entry at least one name to be found by", () => { + const { index } = analyteNameIndex(); + const reached = new Set(index.values()); + for (const entry of allAnalyteConversions()) { + expect(reached.has(entry.key), entry.key).toBe(true); + } + }); + + it("covers every catalog seed the registry knows, in all seven languages", () => { + const { index } = analyteNameIndex(); + const seeds = BIOMARKER_CATALOG.filter((seed) => + analyteConversionFor(seed.slug), + ); + expect(seeds.length).toBeGreaterThanOrEqual(6); + for (const seed of seeds) { + const key = analyteConversionFor(seed.slug)!.key; + const names = [...index.entries()].filter(([, k]) => k === key); + expect(names.length, seed.slug).toBeGreaterThan(0); + } + }); + + it("links each seed at its own catalog unit", () => { + for (const seed of BIOMARKER_CATALOG) { + const entry = analyteConversionFor(seed.slug); + if (!entry) continue; + expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true); + } + }); +}); diff --git a/src/lib/labs/analyte-key-store.ts b/src/lib/labs/analyte-key-store.ts new file mode 100644 index 000000000..f11720e53 --- /dev/null +++ b/src/lib/labs/analyte-key-store.ts @@ -0,0 +1,37 @@ +/** + * Link a marker to its analyte for the unit conversion. + * + * The key is set when a marker is created and, for a marker that already + * existed, the first time a reading needs it. It is never overwritten: a key a + * person (or an earlier pass) set stands, and `updateMany` is gated on the + * column still being null, so two concurrent writers converge. A marker whose + * name matches no analyte keeps a null key and gets no conversion. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { matchAnalyteKey } from "./analyte-match"; + +export async function ensureAnalyteKey( + client: Pick, + marker: { + id: string; + name: string; + unit: string; + analyteKey?: string | null; + }, +): Promise { + if (marker.analyteKey) return marker.analyteKey; + const key = matchAnalyteKey({ name: marker.name, unit: marker.unit }); + if (!key) return null; + const { count } = await client.biomarker.updateMany({ + where: { id: marker.id, analyteKey: null }, + data: { analyteKey: key }, + }); + if (count > 0) return key; + // Another writer set it first; read what stands. + const current = await client.biomarker.findUnique({ + where: { id: marker.id }, + select: { analyteKey: true }, + }); + return current?.analyteKey ?? null; +} diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts new file mode 100644 index 000000000..0e04d48f3 --- /dev/null +++ b/src/lib/labs/analyte-match.ts @@ -0,0 +1,81 @@ +/** + * Which analyte a marker is, for the unit conversion. + * + * `matchAnalyteKey` answers from two places: the localised names of the + * catalog seeds the registry knows (so a marker created from the "pick a common + * marker" list is recognised in every language the app ships) and the alias + * list in the catalog. The name must equal one of them as a WHOLE, folded the + * same way on both sides; a part of a name never matches. + * + * Two safeguards keep a link from being wrong: + * - a folded name that two analytes claim matches neither; + * - 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. + */ +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; + +import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog"; +import { analyteAcceptsUnit } from "./unit-conversion/convert"; +import { analyteConversionFor } from "./unit-conversion/registry"; + +/** The folded name without a trailing parenthetical gloss: "Vitamin D (25-OH)" -> "vitamin d". */ +function withoutGloss(folded: string): string { + return folded.replace(/\s*\([^)]*\)/gu, "").trim(); +} + +let cache: ReadonlyMap | null = null; + +/** + * folded name -> registry key, with every name two analytes claim removed. + * Exported for the guard test, which asserts nothing was dropped silently. + */ +export function analyteNameIndex(): { + index: ReadonlyMap; + ambiguous: readonly string[]; +} { + const claims = new Map>(); + const claim = (name: string, key: string) => { + const folded = foldForMatch(name); + if (!folded) return; + const keys = claims.get(folded) ?? new Set(); + keys.add(key); + claims.set(folded, keys); + }; + + for (const seed of BIOMARKER_CATALOG) { + const entry = analyteConversionFor(seed.slug); + if (!entry) continue; + for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { + claim(name, entry.key); + // The bare name too, for a marker typed without the app's gloss. + claim(withoutGloss(foldForMatch(name)), entry.key); + } + } + for (const { key, names } of ANALYTE_ALIASES) { + const entry = analyteConversionFor(key); + if (!entry) continue; + for (const name of names) claim(name, entry.key); + } + + const index = new Map(); + const ambiguous: string[] = []; + for (const [folded, keys] of claims) { + if (keys.size === 1) index.set(folded, [...keys][0]); + else ambiguous.push(folded); + } + return { index, ambiguous }; +} + +export function matchAnalyteKey(input: { + name: string; + unit: string; +}): string | null { + cache ??= analyteNameIndex().index; + const key = cache.get(foldForMatch(input.name)); + if (!key) return null; + return analyteAcceptsUnit(key, input.unit) ? key : null; +} diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 6b33f8569..3bcf120af 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -276,3 +276,150 @@ export const BIOMARKER_PANELS = [ "electrolytes", "bloodCount", ] as const; + +/** + * 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. + * + * 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, + * or one of the localised names of a seed the registry knows (`ldl`, `hba1c`, + * `fasting-glucose`, ...). The match is the WHOLE name, never a part of it, so + * "Calcium (ionisiert)" is not "Calcium" and "Urea nitrogen" is not "Urea". A + * name that two analytes claim is left out of the match, and a marker named in + * some other way stays unlinked, which is the safe default. + * + * Names are compared folded (lower case, no accents, hyphens and slashes as + * spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose" + * needs no entry of its own. The registry-only keys (urea, blood urea + * nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their + * names live only here. + */ +export interface AnalyteAliases { + /** The registry key of the analyte. */ + key: string; + names: readonly string[]; +} + +export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ + { + key: "fasting-glucose", + names: [ + "glucose", + "blood glucose", + "blood sugar", + "plasma glucose", + "fasting blood glucose", + "fasting blood sugar", + "fasting plasma glucose", + "glukose", + "blutglukose", + "blutzucker", + "nuchternblutzucker", + "glucosa", + "glicemia", + "glycemie", + "glucosio", + "glukoza", + "glikemia", + ], + }, + { + key: "hba1c", + names: [ + "hb a1c", + "a1c", + "hemoglobin a1c", + "glycated hemoglobin", + "glycosylated hemoglobin", + "glykohamoglobin", + "hamoglobin a1c", + ], + }, + { + key: "total-cholesterol", + names: [ + "cholesterol", + "cholesterin", + "cholesterol total", + "cholesterin gesamt", + "cholesterol gesamt", + "colesterol", + "colesterolo", + "cholesterol calkowity", + ], + }, + { key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] }, + { key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] }, + { + key: "creatinine", + names: [ + "serum creatinine", + "serumkreatinin", + "kreatinin im serum", + "creatinine serique", + ], + }, + { + key: "vitamin-d", + names: [ + "25 oh vitamin d", + "25 hydroxy vitamin d", + "25 hydroxyvitamin d", + "25 oh d", + "calcidiol", + "vitamin d 25 oh", + ], + }, + { key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] }, + { + key: "blood-urea-nitrogen", + names: [ + "bun", + "blood urea nitrogen", + "urea nitrogen", + "harnstoff stickstoff", + "stickstoff harnstoff", + "azote ureique", + ], + }, + { + key: "uric-acid", + names: [ + "uric acid", + "urate", + "harnsaure", + "acide urique", + "acido urico", + "kwas moczowy", + ], + }, + { + key: "calcium-total", + names: [ + "calcium", + "total calcium", + "calcium total", + "calcium gesamt", + "gesamtcalcium", + "kalzium", + "kalzium gesamt", + "gesamtkalzium", + "calcio", + "wapn", + ], + }, + { + key: "bilirubin-total", + names: [ + "bilirubin", + "total bilirubin", + "bilirubin total", + "bilirubin gesamt", + "gesamtbilirubin", + "bilirrubina", + "bilirubine", + "bilirubina", + ], + }, +]; diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index f1e11411e..d987fc8ba 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -13,6 +13,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ @@ -120,6 +121,7 @@ export async function resolveOrMintBiomarker( lowerBound: input.referenceLow, upperBound: input.referenceHigh, panel: input.panel, + analyteKey: matchAnalyteKey({ name, unit: input.unit }), }, select, }); diff --git a/src/lib/labs/unit-conversion/convert.ts b/src/lib/labs/unit-conversion/convert.ts index 9e108489b..1c79b5e2a 100644 --- a/src/lib/labs/unit-conversion/convert.ts +++ b/src/lib/labs/unit-conversion/convert.ts @@ -165,3 +165,19 @@ export function conversionsFor(input: { found.delete(to); return [...found].filter((unit) => normaliseLabUnit(unit) !== to); } + +/** + * Whether the analyte's entry reaches `unit`: the unit is one it lists, or + * differs from one only by a power of ten. A marker is linked to an analyte only + * when its own unit passes this, so a marker called "Glucose" tracked in some + * unit the registry cannot reach is not given a conversion it cannot make. + */ +export function analyteAcceptsUnit( + analyteKey: string | null | undefined, + unit: string, +): boolean { + const entry = analyteConversionFor(analyteKey); + return ( + entry !== null && intoEntryUnit(entry, normaliseLabUnit(unit)) !== null + ); +} diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index d210b0b84..6ea98d8c8 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -53,6 +53,7 @@ const biomarkerRow = z lowerBound: z.number().nullable(), upperBound: z.number().nullable(), panel: z.string().nullable(), + analyteKey: z.string().nullable(), hasContext: z.boolean(), context: z.string().nullable(), hidden: z.boolean(), @@ -62,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. `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 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.", }); const listResponse = z diff --git a/tests/integration/labs-analyte-key.test.ts b/tests/integration/labs-analyte-key.test.ts new file mode 100644 index 000000000..df12eb7c3 --- /dev/null +++ b/tests/integration/labs-analyte-key.test.ts @@ -0,0 +1,187 @@ +/** + * A marker is linked to its analyte, once, and the link is never overwritten. + * + * `Biomarker.analyteKey` says which analyte a marker is, so a reading in another + * unit can be converted (#1095). It is set when a marker is created, by all + * three creation paths, and, for a marker that already exists, the first time + * it is needed. Against a real Postgres: + * - a marker created by name is linked when the name is a known analyte and + * its unit is one the analyte reaches, and left null otherwise; + * - the key reaches the API response; + * - `ensureAnalyteKey` fills a null key once and never replaces a key that is + * set, even by a different match, and two concurrent calls converge; + * - the mint path used by the lab writes and the legacy backfill set it too. + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill"; +import { ensureAnalyteKey } from "@/lib/labs/analyte-key-store"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "analyte-key-user"; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); +}); + +async function post(body: Record) { + const s = await prisma.session.create({ + data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { POST } = await import("@/app/api/biomarkers/route"); + return POST( + new NextRequest("http://localhost/api/biomarkers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +describe("POST /api/biomarkers", () => { + it("links a marker whose name is a known analyte, and returns the key", async () => { + const res = await post({ name: "Blutzucker", unit: "mg/dL" }); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey).toBe("fasting-glucose"); + const row = await prisma.biomarker.findFirst({ + where: { userId: USER, name: "Blutzucker" }, + }); + expect(row?.analyteKey).toBe("fasting-glucose"); + }); + + it("links a catalog seed by its localised name", async () => { + const res = await post({ name: "Gesamtcholesterin", unit: "mg/dL" }); + const { data } = (await res.json()) as { data: { analyteKey: string } }; + expect(data.analyteKey).toBe("total-cholesterol"); + }); + + 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"], + ["Glucose", "mEq/L"], + ["Calcium (ionisiert)", "mmol/L"], + ]) { + const res = await post({ name, unit }); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { + data: { analyteKey: string | null }; + }; + expect(data.analyteKey, name).toBeNull(); + } + }); +}); + +describe("ensureAnalyteKey", () => { + it("fills a null key once", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Harnsäure", unit: "µmol/L" }, + }); + expect(marker.analyteKey).toBeNull(); + expect(await ensureAnalyteKey(prisma, marker)).toBe("uric-acid"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("uric-acid"); + }); + + it("never replaces a key that is set, even when the name would match another", async () => { + const marker = await prisma.biomarker.create({ + data: { + userId: USER, + name: "Blood glucose", + unit: "mg/dL", + analyteKey: "hba1c", + }, + }); + // The caller read the row with the key set. + expect(await ensureAnalyteKey(prisma, marker)).toBe("hba1c"); + // A caller holding a stale copy with a null key does not overwrite either. + expect( + await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }), + ).toBe("hba1c"); + expect( + (await prisma.biomarker.findUnique({ where: { id: marker.id } })) + ?.analyteKey, + ).toBe("hba1c"); + }); + + it("converges when two callers fill the same marker", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Kreatinin", unit: "mg/dL" }, + }); + const [a, b] = await Promise.all([ + ensureAnalyteKey(prisma, marker), + ensureAnalyteKey(prisma, marker), + ]); + expect(a).toBe("creatinine"); + expect(b).toBe("creatinine"); + }); + + it("leaves a marker with no analyte null", async () => { + const marker = await prisma.biomarker.create({ + data: { userId: USER, name: "Ferritin", unit: "ng/mL" }, + }); + expect(await ensureAnalyteKey(prisma, marker)).toBeNull(); + }); +}); + +describe("the other creation paths", () => { + it("links a marker minted by a lab write", async () => { + const minted = await resolveOrMintBiomarker(USER, { + analyte: "Gesamtbilirubin", + unit: "µmol/L", + referenceLow: null, + referenceHigh: null, + panel: null, + }); + const row = await prisma.biomarker.findUnique({ where: { id: minted.id } }); + expect(row?.analyteKey).toBe("bilirubin-total"); + }); + + it("links a marker the legacy backfill mints", async () => { + await prisma.labResult.create({ + data: { + userId: USER, + analyte: "Kalzium", + value: 2.4, + unit: "mmol/L", + takenAt: new Date("2026-05-01T08:00:00Z"), + source: "MANUAL", + }, + }); + await runLabBiomarkerBackfillForUser(USER); + const row = await prisma.biomarker.findFirst({ + where: { userId: USER, name: "Kalzium" }, + }); + expect(row?.analyteKey).toBe("calcium-total"); + }); +});