mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Add a user-scoped Biomarker catalog so a marker is defined once — name, unit, lower/upper bound, optional encrypted context, panel — and every later reading just links it. New /api/biomarkers CRUD plus a structured /api/labs create path: a biomarkerId resolves the unit + reference range server-side, so the list and detail DTOs carry the resolved values and the client never recomputes the range. Lab reads include the linked marker and fall back to the legacy free-text fields for unlinked rows. Add a lab-result restore endpoint for the delete-Undo affordance, a shared serialiser, a static common-panel seed list, and tests for catalog validation, structured-entry resolution, and the context codec.
151 lines
4.2 KiB
TypeScript
151 lines
4.2 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.
|
|
*
|
|
* Either way the verdict (`rangeStatus`) is computed from the RESOLVED bounds
|
|
* via `classifyReferenceRange`. The DTO carries the resolved values so the web
|
|
* client AND the iOS client render the same numbers — neither recomputes the
|
|
* range or guesses the unit. This is the server-authoritative-parity rule for
|
|
* Labs.
|
|
*/
|
|
import { classifyReferenceRange } 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;
|
|
}
|
|
|
|
/** The lab-result row shape the resolver reads (Prisma row subset). */
|
|
export interface LabRow {
|
|
id: string;
|
|
panel: string | null;
|
|
analyte: string;
|
|
value: number;
|
|
unit: string;
|
|
referenceLow: number | null;
|
|
referenceHigh: number | null;
|
|
takenAt: Date;
|
|
source: string;
|
|
biomarkerId: string | null;
|
|
noteEncrypted: Uint8Array | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
/**
|
|
* Resolve the canonical name / unit / bounds / panel for a lab row, preferring
|
|
* the linked biomarker over the legacy per-row fields. Returns a plain shape
|
|
* the list + detail serialisers both build on.
|
|
*/
|
|
export function resolveLabFields(
|
|
row: Pick<
|
|
LabRow,
|
|
| "analyte"
|
|
| "unit"
|
|
| "referenceLow"
|
|
| "referenceHigh"
|
|
| "panel"
|
|
| "biomarkerId"
|
|
>,
|
|
biomarker: ResolvedBiomarker | null | undefined,
|
|
): {
|
|
analyte: string;
|
|
unit: string;
|
|
referenceLow: number | null;
|
|
referenceHigh: number | null;
|
|
panel: string | null;
|
|
} {
|
|
if (biomarker) {
|
|
return {
|
|
analyte: biomarker.name,
|
|
unit: biomarker.unit,
|
|
referenceLow: biomarker.lowerBound,
|
|
referenceHigh: biomarker.upperBound,
|
|
panel: biomarker.panel,
|
|
};
|
|
}
|
|
return {
|
|
analyte: row.analyte,
|
|
unit: row.unit,
|
|
referenceLow: row.referenceLow,
|
|
referenceHigh: row.referenceHigh,
|
|
panel: row.panel,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
return {
|
|
id: row.id,
|
|
biomarkerId: row.biomarkerId,
|
|
panel: resolved.panel,
|
|
analyte: resolved.analyte,
|
|
value: row.value,
|
|
unit: resolved.unit,
|
|
referenceLow: resolved.referenceLow,
|
|
referenceHigh: resolved.referenceHigh,
|
|
takenAt: row.takenAt.toISOString(),
|
|
source: row.source,
|
|
hasNote: row.noteEncrypted !== null,
|
|
rangeStatus: classifyReferenceRange(
|
|
row.value,
|
|
resolved.referenceLow,
|
|
resolved.referenceHigh,
|
|
),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
return {
|
|
id: row.id,
|
|
biomarkerId: row.biomarkerId,
|
|
panel: resolved.panel,
|
|
analyte: resolved.analyte,
|
|
value: row.value,
|
|
unit: resolved.unit,
|
|
referenceLow: resolved.referenceLow,
|
|
referenceHigh: resolved.referenceHigh,
|
|
takenAt: row.takenAt.toISOString(),
|
|
source: row.source,
|
|
note,
|
|
rangeStatus: classifyReferenceRange(
|
|
row.value,
|
|
resolved.referenceLow,
|
|
resolved.referenceHigh,
|
|
),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|