mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(labs): link a marker to its analyte for the unit conversion
Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing said which analyte it was. This adds the explicit alias list in the catalog the issue settled on, including the registry-only keys (urea, blood urea nitrogen, uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key (from migration 0385) from it. matchAnalyteKey equals a marker's folded name against the localised names of the catalog seeds the registry knows and against the alias list. It matches the whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes claim matches neither; and the marker's own unit must be one the analyte reaches, so a marker called "Glucose" in a unit the registry cannot convert stays unlinked. The key is set when a marker is created (the biomarker route, the mint used by lab writes, the legacy backfill) and, for an existing marker, by ensureAnalyteKey the first time a reading needs it. It never overwrites a key that is set. There is no boot job for the existing markers: the conversion that uses the key fills it on first use, and a sweep can be added later if wanted. The key is returned on the biomarker API and in the OpenAPI schema. Refs #1095
This commit is contained in:
1 parent
c2be166f72
commit
52ea3b9c2f
13 files changed
+660
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<PrismaClient, "biomarker">,
|
||||
marker: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
analyteKey?: string | null;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string> | 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<string, string>;
|
||||
ambiguous: readonly string[];
|
||||
} {
|
||||
const claims = new Map<string, Set<string>>();
|
||||
const claim = (name: string, key: string) => {
|
||||
const folded = foldForMatch(name);
|
||||
if (!folded) return;
|
||||
const keys = claims.get(folded) ?? new Set<string>();
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user