mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
295 lines
11 KiB
TypeScript
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(),
|
|
};
|
|
}
|