Files
HealthLog/src/lib/labs/serialise.ts
T
Marc-André Bombeck 07f09e1b61 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.
2026-10-14 19:18:49 +02:00

295 lines
11 KiB
TypeScript

/**
* v1.18.1 — server-authoritative lab-result DTO resolution.
*
* A lab row resolves its display name + unit + reference bounds in ONE place:
*
* - If the row links a `Biomarker` (`biomarkerId` set), the canonical name,
* unit, and reference bounds come FROM THE BIOMARKER. The per-row legacy
* `analyte` / `unit` / `reference*` columns are ignored — they are stale
* historical truth at that point.
* - If the row is unlinked (legacy / pre-backfill), it falls back to its own
* free-text `analyte` / `unit` / `reference*` columns.
*
* On top of that the reading's OWN printed window wins. A lab report states
* the range its method and device produce right beside the value, and that is
* the window a physician evaluates against; the catalog band is the net for
* readings that carry none. `resolveEffectiveReferenceRange` owns that
* precedence and this file is one of its callers, not a second copy of it.
*
* The DTO therefore carries three windows and says which one is in force:
*
* - `referenceLow` / `referenceHigh` — the EFFECTIVE bounds, the ones
* `rangeStatus` was computed from. A client that reads only these fields
* (including an iOS build that predates the source window) renders the
* right numbers and the right verdict with no change of its own.
* - `catalogReferenceLow` / `catalogReferenceHigh` — the catalog band, so a
* surface can paint both and show where they part.
* - `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` —
* what the report printed, the last verbatim. For a reading converted into
* its marker's unit the bounds were converted with the value, but the text
* is still in the unit the report printed, so it is never sent as
* `sourceReferenceText` (that would put two units in one statement): it
* travels as `printedReferenceText`, in `sourceUnit`.
*
* `referenceOrigin` names which window is in force and
* `referenceDivergesFromCatalog` flags the case a reader must be told about.
* Every value is resolved server-side; neither client recomputes a range,
* guesses a unit, or re-derives the verdict.
*
* The two serialisers below spell every field out rather than spreading a
* shared helper: `written-outcome-response-consumer-guard` walks these object
* literals to enumerate the response leaves and pair each with a client
* reader, and a spread of a function call hides them from it. A field added
* here must be visible there.
*/
import {
classifyAgainstEffectiveRange,
resolveEffectiveReferenceRange,
type EffectiveReferenceRange,
type ReferenceRangeOrigin,
} from "@/lib/labs/reference-range";
/** The minimal biomarker shape the resolver needs (no encrypted context). */
export interface ResolvedBiomarker {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
/** Which analyte the marker is (catalog slug or registry-only key), if known. */
analyteKey?: string | null;
}
/** The lab-result row shape the resolver reads (Prisma row subset). */
export interface LabRow {
id: string;
panel: string | null;
analyte: string;
/** Numeric reading; null for a qualitative row (see `valueText`). */
value: number | null;
/** v1.18.9 — qualitative result text; null for a numeric row. */
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** The window the source report printed for THIS reading. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
takenAt: Date;
source: string;
biomarkerId: string | null;
/**
* When the reading was converted into the marker's unit: the value and unit
* as the report printed them. Both null for a reading stored as stated.
*/
sourceValue: number | null;
sourceUnit: string | null;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}
/** The columns `resolveLabFields` needs off a lab row. */
export type LabFieldRow = Pick<
LabRow,
| "analyte"
| "unit"
| "referenceLow"
| "referenceHigh"
| "sourceReferenceLow"
| "sourceReferenceHigh"
| "sourceReferenceText"
| "sourceUnit"
| "panel"
| "biomarkerId"
>;
/** What every lab surface reads off a row: identity, unit, and one window. */
export interface ResolvedLabFields {
analyte: string;
unit: string;
panel: string | null;
/** The bounds in force for this reading — source window first, catalog next. */
referenceLow: number | null;
referenceHigh: number | null;
/** The catalog band, so a surface can show both windows. */
catalogReferenceLow: number | null;
catalogReferenceHigh: number | null;
/** What the source report printed for this reading. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
/** Null for a converted reading; see `printedReferenceText`. */
sourceReferenceText: string | null;
/**
* A converted reading's printed range text, verbatim, in `sourceUnit` (the
* unit the report printed); null for every other reading.
*/
printedReferenceText: string | null;
referenceOrigin: ReferenceRangeOrigin;
referenceDivergesFromCatalog: boolean;
/** The resolved window as one value, for callers that pass it on. */
effectiveRange: EffectiveReferenceRange;
}
/**
* Resolve the canonical name / unit / window / panel for a lab row.
*
* Two resolutions happen here, in this order:
*
* 1. Identity + catalog band: the linked biomarker wins over the row's legacy
* free-text columns (an unlinked pre-backfill row falls back to its own).
* 2. The window in force: the reading's printed source range wins over the
* catalog band, through `resolveEffectiveReferenceRange`.
*
* Every lab surface calls this, so the window a reading is judged against is
* the same on the API, the doctor report, insights, the coach, MCP and FHIR.
*/
export function resolveLabFields(
row: LabFieldRow,
biomarker: ResolvedBiomarker | null | undefined,
): ResolvedLabFields {
const identity = biomarker
? {
analyte: biomarker.name,
unit: biomarker.unit,
panel: biomarker.panel,
catalogLow: biomarker.lowerBound,
catalogHigh: biomarker.upperBound,
}
: {
analyte: row.analyte,
unit: row.unit,
panel: row.panel,
catalogLow: row.referenceLow,
catalogHigh: row.referenceHigh,
};
const effectiveRange = resolveEffectiveReferenceRange(
identity.catalogLow,
identity.catalogHigh,
row,
);
return {
analyte: identity.analyte,
unit: identity.unit,
panel: identity.panel,
referenceLow: effectiveRange.low,
referenceHigh: effectiveRange.high,
catalogReferenceLow: effectiveRange.catalogLow,
catalogReferenceHigh: effectiveRange.catalogHigh,
sourceReferenceLow: row.sourceReferenceLow,
sourceReferenceHigh: row.sourceReferenceHigh,
sourceReferenceText: effectiveRange.sourceText,
printedReferenceText: effectiveRange.printed?.text ?? null,
referenceOrigin: effectiveRange.origin,
referenceDivergesFromCatalog: effectiveRange.divergesFromCatalog,
effectiveRange,
};
}
/**
* Serialise a lab row to the list DTO — never echoes the encrypted note bytes
* (only the `hasNote` flag) and resolves name/unit/range server-side.
*/
export function serialiseLabResult(
row: LabRow,
biomarker?: ResolvedBiomarker | null,
) {
const resolved = resolveLabFields(row, biomarker);
const conversion = conversionFields(row);
return {
id: row.id,
biomarkerId: row.biomarkerId,
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
valueText: row.valueText,
unit: resolved.unit,
converted: conversion.converted,
sourceValue: conversion.sourceValue,
sourceUnit: conversion.sourceUnit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
catalogReferenceLow: resolved.catalogReferenceLow,
catalogReferenceHigh: resolved.catalogReferenceHigh,
sourceReferenceLow: resolved.sourceReferenceLow,
sourceReferenceHigh: resolved.sourceReferenceHigh,
sourceReferenceText: resolved.sourceReferenceText,
printedReferenceText: resolved.printedReferenceText,
referenceOrigin: resolved.referenceOrigin,
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
// A qualitative row has no number to place against bounds, so it reports
// the neutral "unknown" rather than a fabricated in/out verdict.
rangeStatus: classifyAgainstEffectiveRange(
row.value,
resolved.effectiveRange,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Whether the stored number is a conversion, and what the report printed. A
* reading stored as stated reports `converted: false` and two nulls.
*/
function conversionFields(row: Pick<LabRow, "sourceValue" | "sourceUnit">) {
const sourceValue = row.sourceValue ?? null;
const sourceUnit = row.sourceUnit ?? null;
return {
converted: sourceValue !== null && sourceUnit !== null,
sourceValue,
sourceUnit,
};
}
/**
* Serialise a lab row to the detail DTO — same as the list DTO but carries the
* decrypted `note` instead of the `hasNote` flag.
*/
export function serialiseLabResultDetail(
row: LabRow,
biomarker: ResolvedBiomarker | null | undefined,
note: string | null,
) {
const resolved = resolveLabFields(row, biomarker);
const conversion = conversionFields(row);
return {
id: row.id,
biomarkerId: row.biomarkerId,
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
valueText: row.valueText,
unit: resolved.unit,
converted: conversion.converted,
sourceValue: conversion.sourceValue,
sourceUnit: conversion.sourceUnit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
catalogReferenceLow: resolved.catalogReferenceLow,
catalogReferenceHigh: resolved.catalogReferenceHigh,
sourceReferenceLow: resolved.sourceReferenceLow,
sourceReferenceHigh: resolved.sourceReferenceHigh,
sourceReferenceText: resolved.sourceReferenceText,
printedReferenceText: resolved.printedReferenceText,
referenceOrigin: resolved.referenceOrigin,
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
source: row.source,
note,
rangeStatus: classifyAgainstEffectiveRange(
row.value,
resolved.effectiveRange,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}