feat(labs): record qualitative lab results

Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:17:43 +02:00
1 parent e631396af3
commit a5b3715a46
30 files changed
+819 -123

No files matched your search

+22
View File
@@ -150,6 +150,24 @@ export const PUT = apiHandler(
);
}
// v1.18.9 — a row keeps its type. Editing `value` on a qualitative row or
// `valueText` on a numeric row would either leave both columns populated or
// strand the row in an ambiguous state. Reject the cross-type edit with a
// 422 — switch types by deleting and re-adding.
const rowIsQualitative = existing.valueText !== null;
if (rowIsQualitative && d.value !== undefined) {
return apiError(
"This is a qualitative reading — edit its result text, not a numeric value",
422,
);
}
if (!rowIsQualitative && d.valueText !== undefined) {
return apiError(
"This is a numeric reading — edit its value, not a qualitative result",
422,
);
}
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
// A reading linked to a catalog marker is edited via VALUE / takenAt /
@@ -158,6 +176,10 @@ export const PUT = apiHandler(
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
if (d.value !== undefined) data.value = d.value;
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
// qualitative row (or `valueText` on a numeric row) crosses the row's type
// and is rejected below. An edit of the matching field updates in place.
if (d.valueText !== undefined) data.valueText = d.valueText;
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
+14 -4
View File
@@ -173,6 +173,7 @@ async function postLabResult(request: NextRequest) {
panel,
analyte,
value,
valueText,
unit,
referenceLow,
referenceHigh,
@@ -180,6 +181,10 @@ async function postLabResult(request: NextRequest) {
note,
} = parsed.data;
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
// of a number. The Zod refine guarantees exactly one of the two is present.
const isQualitative = valueText !== undefined;
// Every row links a catalog marker — no `LabResult` ever persists unlinked
// (v1.18.1 High). Two paths converge on a `biomarker`:
// - Structured: a `biomarkerId` is supplied; resolve + verify ownership
@@ -207,9 +212,12 @@ async function postLabResult(request: NextRequest) {
} else {
biomarker = await resolveOrMintBiomarker(user.id, {
analyte: analyte as string,
unit: unit as string,
referenceLow: referenceLow ?? null,
referenceHigh: referenceHigh ?? null,
// A qualitative reading has no numeric unit / range — mint the catalog
// marker with an empty unit label and no bounds. A numeric reading
// supplies its unit (Zod requires it on the free-text numeric path).
unit: isQualitative ? (unit ?? "") : (unit as string),
referenceLow: isQualitative ? null : (referenceLow ?? null),
referenceHigh: isQualitative ? null : (referenceHigh ?? null),
panel: panel ?? null,
});
}
@@ -224,7 +232,9 @@ async function postLabResult(request: NextRequest) {
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value,
// Exactly one of value / valueText is set (the Zod XOR refine).
value: value ?? null,
valueText: valueText ?? null,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,