merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill

# Conflicts:
#	src/app/api/labs/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:09 +02:00
commit cc32e2adf7
40 files changed
+4403 -360

No files matched your search

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { groupRowsByAnalyte } from "../lab-biomarker-backfill";
function row(
id: string,
analyte: string,
unit: string,
takenAt: string,
opts: { low?: number | null; high?: number | null; panel?: string | null } = {},
) {
return {
id,
analyte,
unit,
referenceLow: opts.low ?? null,
referenceHigh: opts.high ?? null,
panel: opts.panel ?? null,
takenAt: new Date(takenAt),
};
}
describe("groupRowsByAnalyte", () => {
it("collapses case/spacing variants of the same analyte into one group", () => {
const groups = groupRowsByAnalyte([
row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"),
row("b", "ldl", "mg/dl", "2026-02-01T00:00:00Z"),
row("c", " LDL ", "mg/dL", "2026-03-01T00:00:00Z"),
]);
expect(groups).toHaveLength(1);
expect(groups[0].ids.sort()).toEqual(["a", "b", "c"]);
});
it("uses the most-recently-taken reading for the canonical name/unit/range", () => {
// Same analyte key ("ldl"), case + unit + range drift across reports —
// the newest report's spelling/unit/range wins.
const groups = groupRowsByAnalyte([
row("old", "ldl", "mg/dl", "2026-01-01T00:00:00Z", { high: 100 }),
row("new", "LDL", "mg/dL", "2026-06-01T00:00:00Z", { high: 116 }),
]);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("LDL");
expect(groups[0].unit).toBe("mg/dL");
expect(groups[0].referenceHigh).toBe(116);
});
it("keeps distinct analytes in separate groups", () => {
const groups = groupRowsByAnalyte([
row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"),
row("b", "HDL", "mg/dL", "2026-01-01T00:00:00Z"),
]);
expect(groups).toHaveLength(2);
});
it("skips a row with a blank analyte", () => {
const groups = groupRowsByAnalyte([
row("a", " ", "mg/dL", "2026-01-01T00:00:00Z"),
row("b", "LDL", "mg/dL", "2026-01-01T00:00:00Z"),
]);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("LDL");
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* v1.18.1 — one-shot backfill that links legacy `LabResult` rows to a
* user-scoped `Biomarker` catalog entry.
*
* Before the catalog existed, every lab reading carried its own free-text
* `analyte` / `unit` / `reference*`. Recording "LDL" three times forked the
* "same" marker into "LDL" / "ldl" / "LDL-C" with possibly diverging units
* and ranges. This pass heals that at the source: for each user, it groups
* every un-linked live reading by `lower(analyte)`, creates ONE `Biomarker`
* per group (using the most-recently-taken reading's spelling / unit /
* reference bounds — "last lab report wins"), and stamps `biomarkerId` on
* every row in the group.
*
* Idempotent across reboots: the discovery predicate is "the user has at
* least one live reading with `biomarkerId IS NULL`". A completed pass leaves
* every reading linked, so the user drops out of the discovery set. A reboot
* mid-pass re-runs from scratch — `Biomarker` creation upserts on
* `(userId, name)` and the row-link `updateMany` is `biomarkerId: null`-gated,
* so the result converges. A reading whose marker already exists (e.g. the
* user defined it by hand) re-uses that row rather than minting a duplicate.
*
* Modelled on `sleep-timeline-backfill.ts` / `whoop-backfill.ts`. The queue
* name MUST be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts`
* or pg-boss never provisions it and the boot enqueue silently never drains
* (the v1.4.37 dead-queue class).
*/
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill";
/**
* Serial concurrency — the per-user pass is a short burst of grouped writes;
* concurrency-1 keeps it off the request pool, matching the other backfill
* queues.
*/
export const LAB_BIOMARKER_BACKFILL_CONCURRENCY = 1;
export interface LabBiomarkerBackfillPayload {
userId: string;
enqueuedAt: string;
}
/** A single legacy reading the grouping reads. */
interface LegacyRow {
id: string;
analyte: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
takenAt: Date;
}
interface AnalyteGroup {
/** Canonical spelling = the most-recently-taken reading's `analyte`. */
name: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
ids: string[];
}
/**
* Reduce the user's un-linked rows into one group per `lower(analyte)`. The
* canonical name / unit / range come from the most-recently-TAKEN reading in
* each group ("last lab report wins"); every row id rides into the group so a
* single `updateMany` can link them. Exported for unit testing.
*/
export function groupRowsByAnalyte(rows: LegacyRow[]): AnalyteGroup[] {
const byKey = new Map<string, LegacyRow[]>();
for (const r of rows) {
const key = r.analyte.trim().toLowerCase();
if (!key) continue;
const bucket = byKey.get(key);
if (bucket) bucket.push(r);
else byKey.set(key, [r]);
}
const groups: AnalyteGroup[] = [];
for (const bucket of byKey.values()) {
// Newest reading defines the canonical name / unit / range.
const newest = bucket.reduce((a, b) =>
b.takenAt.getTime() >= a.takenAt.getTime() ? b : a,
);
groups.push({
name: newest.analyte.trim(),
unit: newest.unit,
referenceLow: newest.referenceLow,
referenceHigh: newest.referenceHigh,
panel: newest.panel,
ids: bucket.map((r) => r.id),
});
}
return groups;
}
/**
* Backfill one user: group un-linked live readings, create/reuse a Biomarker
* per group, link the rows. Returns the count of markers touched + rows
* linked.
*/
export async function runLabBiomarkerBackfillForUser(
userId: string,
): Promise<{ markers: number; linked: number }> {
const rows = await prisma.labResult.findMany({
where: { userId, deletedAt: null, biomarkerId: null },
select: {
id: true,
analyte: true,
unit: true,
referenceLow: true,
referenceHigh: true,
panel: true,
takenAt: true,
},
});
if (rows.length === 0) {
return { markers: 0, linked: 0 };
}
const groups = groupRowsByAnalyte(rows);
let markers = 0;
let linked = 0;
for (const group of groups) {
// Re-use a hand-defined marker of the same name if one exists; otherwise
// mint it. `(userId, name)` is unique, so a concurrent re-run converges.
const biomarker = await prisma.biomarker.upsert({
where: { userId_name: { userId, name: group.name } },
// An existing marker keeps its own (possibly user-tuned) unit / range.
update: {},
create: {
userId,
name: group.name,
unit: group.unit,
lowerBound: group.referenceLow,
upperBound: group.referenceHigh,
panel: group.panel,
},
});
markers += 1;
const { count } = await prisma.labResult.updateMany({
where: { id: { in: group.ids }, userId, biomarkerId: null },
data: { biomarkerId: biomarker.id },
});
linked += count;
}
annotate({
action: {
name: "labs.biomarker.backfill.complete",
details: { markers, linked },
},
});
return { markers, linked };
}
/**
* Boot-time discovery. Finds every user holding at least one un-linked live
* lab reading and enqueues one job each. Idempotent across reboots: a
* completed pass links every reading, dropping the user from the discovery
* set. `singletonKey` coalesces duplicate sends.
*
* Best-effort: errors come back through the result value so worker boot never
* fails because of a backfill miss.
*/
export async function enqueueBootTimeLabBiomarkerBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const rows = await prisma.labResult.findMany({
where: { deletedAt: null, biomarkerId: null },
select: { userId: true },
distinct: ["userId"],
});
if (rows.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of rows) {
const payload: LabBiomarkerBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(LAB_BIOMARKER_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `lab-biomarker-backfill|${userId}`,
});
if (jobId) enqueued += 1;
else skipped += 1;
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+58
View File
@@ -28,6 +28,13 @@ import {
enqueueBootTimeSleepTimelineBackfill,
type SleepTimelineBackfillPayload,
} from "@/lib/jobs/sleep-timeline-backfill";
import {
LAB_BIOMARKER_BACKFILL_QUEUE,
LAB_BIOMARKER_BACKFILL_CONCURRENCY,
runLabBiomarkerBackfillForUser,
enqueueBootTimeLabBiomarkerBackfill,
type LabBiomarkerBackfillPayload,
} from "@/lib/jobs/lab-biomarker-backfill";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -662,6 +669,11 @@ export async function startReminderWorker() {
// pg-boss never provisions it and the boot enqueue silently never drains
// (the v1.4.37 dead-queue class).
SLEEP_TIMELINE_BACKFILL_QUEUE,
// v1.18.1 — one-shot backfill that links legacy free-text lab readings to
// a user-scoped Biomarker catalog entry (group by `lower(analyte)`). The
// queue MUST be registered here or pg-boss never provisions it and the
// boot enqueue silently never drains (the v1.4.37 dead-queue class).
LAB_BIOMARKER_BACKFILL_QUEUE,
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no
// backfill queue — the hourly window walks the recent SGV set). The queue
// MUST be registered here or pg-boss never provisions it and the schedule
@@ -1125,6 +1137,25 @@ export async function startReminderWorker() {
}
},
);
// v1.18.1 — one-shot lab-biomarker backfill. The boot enqueue below sends
// one job per user holding un-linked free-text lab readings; this handler
// groups them by `lower(analyte)`, mints/reuses a Biomarker per group, and
// links the rows. Idempotent — a re-run links only what is still un-linked.
await boss.work<LabBiomarkerBackfillPayload>(
LAB_BIOMARKER_BACKFILL_QUEUE,
{ localConcurrency: LAB_BIOMARKER_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const { markers, linked } =
await runLabBiomarkerBackfillForUser(userId);
workerLog(
"info",
`[lab-biomarker-backfill] user=${userId} markers=${markers} linked=${linked}`,
);
}
},
);
// v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no
// `userId`) walks every configured instance; one user's unreachable host is
// warned, not fatal.
@@ -1996,6 +2027,33 @@ export async function startReminderWorker() {
);
}
// v1.18.1 — fire-and-forget boot discovery for the one-shot lab-biomarker
// backfill. Finds every user holding an un-linked live lab reading and
// enqueues one job each. Idempotent across reboots: a completed pass links
// every reading, dropping the user from the discovery set. Errors come back
// through the helper's result value — worker boot never fails on a miss.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeLabBiomarkerBackfill();
if (error) {
workerLog(
"error",
`[lab-biomarker-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[lab-biomarker-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[lab-biomarker-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.7.0 — fire-and-forget boot discovery for the daily-mean
// consolidation pass. Finds every user holding live per-sample
// high-frequency mean-type rows and enqueues one job per account.
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
decryptContextFromBytes,
encryptContextToBytes,
} from "../biomarker-store";
const KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
describe("biomarker context codec", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY);
_resetCryptoCacheForTests();
});
it("round-trips a UTF-8 context note through Bytes", () => {
const plain = "Nüchtern · morgens · Lipidpanel";
const bytes = encryptContextToBytes(plain);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(decryptContextFromBytes(bytes)).toBe(plain);
});
it("does not store the plaintext verbatim in the bytes", () => {
const plain = "secret context";
const bytes = encryptContextToBytes(plain);
const asString = Buffer.from(bytes).toString("utf8");
expect(asString).not.toContain(plain);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
resolveLabFields,
serialiseLabResult,
serialiseLabResultDetail,
type LabRow,
type ResolvedBiomarker,
} from "../serialise";
const baseRow: LabRow = {
id: "lr_1",
panel: "Legacy panel",
analyte: "ldl", // lowercase legacy spelling
value: 130,
unit: "mg/dl", // lowercase legacy unit
referenceLow: null,
referenceHigh: 100, // stale per-row bound
takenAt: new Date("2026-01-01T08:00:00.000Z"),
source: "MANUAL",
biomarkerId: "bm_1",
noteEncrypted: null,
createdAt: new Date("2026-01-01T08:00:00.000Z"),
updatedAt: new Date("2026-01-01T08:00:00.000Z"),
};
const biomarker: ResolvedBiomarker = {
id: "bm_1",
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: null,
upperBound: 116,
panel: "Lipids",
};
describe("resolveLabFields", () => {
it("prefers the linked biomarker over the legacy row fields", () => {
const r = resolveLabFields(baseRow, biomarker);
expect(r.analyte).toBe("LDL Cholesterol");
expect(r.unit).toBe("mg/dL");
expect(r.referenceHigh).toBe(116);
expect(r.panel).toBe("Lipids");
});
it("falls back to the row fields when unlinked", () => {
const r = resolveLabFields({ ...baseRow, biomarkerId: null }, null);
expect(r.analyte).toBe("ldl");
expect(r.unit).toBe("mg/dl");
expect(r.referenceHigh).toBe(100);
expect(r.panel).toBe("Legacy panel");
});
});
describe("serialiseLabResult", () => {
it("computes the verdict from the RESOLVED catalog bounds, not the stale row", () => {
// value 130 vs row's stale high 100 → would be 'above';
// vs catalog high 116 → still 'above', but the resolved bound is what counts.
const dto = serialiseLabResult(baseRow, biomarker);
expect(dto.unit).toBe("mg/dL");
expect(dto.referenceHigh).toBe(116);
expect(dto.rangeStatus).toBe("above");
expect(dto.biomarkerId).toBe("bm_1");
expect(dto.hasNote).toBe(false);
});
it("in-range when the resolved catalog band contains the value", () => {
const within = serialiseLabResult({ ...baseRow, value: 90 }, biomarker);
expect(within.rangeStatus).toBe("in-range");
});
it("uses legacy fields and verdict when unlinked", () => {
const dto = serialiseLabResult({ ...baseRow, biomarkerId: null }, null);
expect(dto.unit).toBe("mg/dl");
// 130 > row high 100 → above
expect(dto.rangeStatus).toBe("above");
expect(dto.biomarkerId).toBeNull();
});
it("flags an encrypted note without echoing it", () => {
const dto = serialiseLabResult(
{ ...baseRow, noteEncrypted: new Uint8Array([1, 2, 3]) },
biomarker,
);
expect(dto.hasNote).toBe(true);
expect(dto).not.toHaveProperty("noteEncrypted");
});
});
describe("serialiseLabResultDetail", () => {
it("carries the decrypted note in place of hasNote", () => {
const dto = serialiseLabResultDetail(baseRow, biomarker, "fasting sample");
expect(dto.note).toBe("fasting sample");
expect(dto).not.toHaveProperty("hasNote");
expect(dto.unit).toBe("mg/dL");
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* v1.18.1 — static seed catalog of common blood/lab biomarkers.
*
* This is NOT a DB table. It is a curated list offered at the add step
* ("pick a common marker, or create your own") so a self-hoster does not
* type a marker definition from scratch. Selecting a seed pre-fills the
* inline definition form (canonical name, unit, suggested reference bounds)
* which the user then accepts or overrides before it becomes a real,
* user-scoped `Biomarker` row.
*
* The suggested ranges are EDITABLE DEFAULTS, never gospel — reference
* windows vary by lab, sex, and age. The display name is localised via the
* `labs.catalog.<slug>` i18n key; the EN slug is the stable identity (mirrors
* the Insights slug/alias pattern). Units stay literal (mg/dL etc. are not
* translated).
*
* Source ranges: common adult fasting reference windows (US conventional
* units where the metric/SI split matters). Presented as starting points.
*/
export interface BiomarkerSeed {
/** Stable EN identity; the `labs.catalog.<slug>` i18n key resolves the name. */
slug: string;
/** Panel grouping key; the `labs.catalog.panel.<panel>` i18n key labels it. */
panel: string;
/** Canonical unit for every reading against this marker. */
unit: string;
/** Suggested lower bound (editable default), or null when open-ended. */
lowerBound: number | null;
/** Suggested upper bound (editable default), or null when open-ended. */
upperBound: number | null;
}
export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
// Lipids
{ slug: "total-cholesterol", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 200 },
{ slug: "ldl", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 116 },
{ slug: "hdl", panel: "lipids", unit: "mg/dL", lowerBound: 40, upperBound: null },
{ slug: "triglycerides", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 150 },
// Glucose metabolism
{ slug: "fasting-glucose", panel: "glucose", unit: "mg/dL", lowerBound: 70, upperBound: 100 },
{ slug: "hba1c", panel: "glucose", unit: "%", lowerBound: null, upperBound: 5.7 },
// Thyroid
{ slug: "tsh", panel: "thyroid", unit: "mIU/L", lowerBound: 0.4, upperBound: 4 },
{ slug: "ft3", panel: "thyroid", unit: "pg/mL", lowerBound: 2.3, upperBound: 4.2 },
{ slug: "ft4", panel: "thyroid", unit: "ng/dL", lowerBound: 0.8, upperBound: 1.8 },
// Iron
{ slug: "ferritin", panel: "iron", unit: "ng/mL", lowerBound: 30, upperBound: 400 },
{ slug: "transferrin-saturation", panel: "iron", unit: "%", lowerBound: 20, upperBound: 50 },
// Vitamins
{ slug: "vitamin-d", panel: "vitamins", unit: "ng/mL", lowerBound: 30, upperBound: 100 },
{ slug: "vitamin-b12", panel: "vitamins", unit: "pg/mL", lowerBound: 200, upperBound: 900 },
{ slug: "folate", panel: "vitamins", unit: "ng/mL", lowerBound: 3, upperBound: 20 },
// Inflammation
{ slug: "hs-crp", panel: "inflammation", unit: "mg/L", lowerBound: null, upperBound: 3 },
// Renal
{ slug: "creatinine", panel: "renal", unit: "mg/dL", lowerBound: 0.6, upperBound: 1.3 },
{ slug: "egfr", panel: "renal", unit: "mL/min/1.73m²", lowerBound: 90, upperBound: null },
// Liver
{ slug: "alt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 },
{ slug: "ast", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 },
{ slug: "ggt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 55 },
// Electrolytes
{ slug: "sodium", panel: "electrolytes", unit: "mmol/L", lowerBound: 135, upperBound: 145 },
{ slug: "potassium", panel: "electrolytes", unit: "mmol/L", lowerBound: 3.5, upperBound: 5.1 },
// Blood count
{ slug: "hemoglobin", panel: "bloodCount", unit: "g/dL", lowerBound: 12, upperBound: 17.5 },
{ slug: "hematocrit", panel: "bloodCount", unit: "%", lowerBound: 36, upperBound: 50 },
{ slug: "wbc", panel: "bloodCount", unit: "10³/µL", lowerBound: 4, upperBound: 11 },
{ slug: "platelets", panel: "bloodCount", unit: "10³/µL", lowerBound: 150, upperBound: 400 },
] as const;
/** The set of panel keys present in the catalog, in display order. */
export const BIOMARKER_PANELS = [
"lipids",
"glucose",
"thyroid",
"iron",
"vitamins",
"inflammation",
"renal",
"liver",
"electrolytes",
"bloodCount",
] as const;
export type BiomarkerPanelKey = (typeof BIOMARKER_PANELS)[number];
+30
View File
@@ -0,0 +1,30 @@
/**
* v1.18.1 — server-side helpers for the user-scoped Biomarker catalog.
*
* The AES-256-GCM ↔ `Bytes` codec for `Biomarker.contextEncrypted`. The
* per-marker context note ("what this means") is the only sensitive column on
* the model; it shares the `encrypt()` string format (`"<keyId>.<base64>"`)
* every other `*Encrypted` column uses, encoded as UTF-8 bytes.
*
* Mirrors the `LabResult.noteEncrypted` codec in `./store.ts` so the two
* encrypted columns on the Labs feature share one byte layout.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
export function encryptContextToBytes(
plaintext: string,
): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a stored `Bytes` context note back to plaintext. */
export function decryptContextFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
+13
View File
@@ -0,0 +1,13 @@
/**
* v1.18.1 — shared lab-value number formatting.
*
* The list card, the detail history rows, and the chart tooltip all trim a
* lab reading the same way: whole numbers render bare, fractional values keep
* up to two decimals (no trailing-zero noise). Centralised so the three
* surfaces stay byte-identical.
*/
export function formatLabValue(value: number): string {
return Number.isInteger(value)
? String(value)
: String(Number(value.toFixed(2)));
}
+150
View File
@@ -0,0 +1,150 @@
/**
* 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(),
};
}
+182
View File
@@ -0,0 +1,182 @@
/**
* OpenAPI route table for the user-scoped Biomarker catalog (`/api/biomarkers`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies reuse the runtime Zod schemas from `@/lib/validations/biomarkers`
* so the wire contract stays single-source. The response shape is declared
* here (the route serialises a derived `hasContext` flag the input schema
* doesn't carry, and returns the decrypted `context`).
*
* The iOS client consumes the biomarker DTO + the `biomarkerId` field on
* `LabResult` to render the resolved unit + reference range without
* recomputing — server-authoritative parity for Labs.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "@/lib/validations/biomarkers";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
createBiomarkerSchema.meta({
id: "CreateBiomarkerRequest",
description:
"Define a user-scoped biomarker ONCE: canonical `name`, `unit`, optional reference bounds (`lowerBound` / `upperBound`; when both present `lowerBound` must not exceed `upperBound`), an optional encrypted `context` note, and an optional `panel` grouping. The name is unique per user (no second 'LDL'). Recording a value later just picks this marker — its unit + range are never re-entered.",
});
updateBiomarkerSchema.meta({
id: "UpdateBiomarkerRequest",
description:
"Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. A rename that collides with another of the caller's markers is rejected 409.",
});
const biomarkerRow = z
.object({
id: z.string(),
name: z.string(),
unit: z.string(),
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
hasContext: z.boolean(),
context: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "Biomarker",
description:
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
const listResponse = z
.object({ biomarkers: z.array(biomarkerRow) })
.meta({ id: "ListBiomarkersResponse" });
const conflict = {
"409": {
description: "A biomarker with this name already exists for the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description: "Biomarker not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/biomarkers": {
get: {
tags: ["Labs"],
summary: "List the caller's biomarker catalog",
description:
"Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and decrypted context.",
responses: {
"200": {
description: "Biomarker catalog.",
content: {
"application/json": {
schema: dataEnvelope(listResponse, "ListBiomarkersEnvelope"),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Labs"],
summary: "Define a biomarker",
description:
"Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: createBiomarkerSchema } },
},
responses: {
"201": {
description: "Created biomarker.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "CreateBiomarkerResponse"),
},
},
},
...conflict,
...stdResponses,
},
},
},
"/api/biomarkers/{id}": {
get: {
tags: ["Labs"],
summary: "Fetch a single biomarker",
description:
"Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Biomarker detail.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
put: {
tags: ["Labs"],
summary: "Edit a biomarker",
description:
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits as `biomarker.update`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateBiomarkerSchema } },
},
responses: {
"200": {
description: "Updated biomarker.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "UpdateBiomarkerResponse"),
},
},
},
...conflict,
...notFound,
...stdResponses,
},
},
delete: {
tags: ["Labs"],
summary: "Delete a biomarker",
description:
"Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteBiomarkerResponse",
),
},
},
},
...notFound,
...stdResponses,
},
},
},
};
+2
View File
@@ -26,6 +26,7 @@ import { authPaths } from "./auth";
import { coachFeedbackPaths, coachPaths } from "./coach";
import { consentPaths } from "./consent";
import { cyclePaths } from "./cycle";
import { biomarkerPaths } from "./biomarkers";
import { devicePaths } from "./devices";
import { healthRecordPaths } from "./health-record";
import { importPaths } from "./import";
@@ -63,6 +64,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...importPaths,
...measurementReminderPaths,
...labsPaths,
...biomarkerPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+34 -1
View File
@@ -47,6 +47,7 @@ const rangeStatusEnum = z
const labResultRow = z
.object({
id: z.string(),
biomarkerId: z.string().nullable(),
panel: z.string().nullable(),
analyte: z.string(),
value: z.number(),
@@ -63,7 +64,7 @@ const labResultRow = z
.meta({
id: "LabResult",
description:
"A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
"A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
});
const labResultDetail = labResultRow
@@ -201,4 +202,36 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/labs/restore": {
post: {
tags: ["Labs"],
summary: "Restore soft-deleted lab results",
description:
"Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger.",
requestBody: {
required: true,
content: {
"application/json": {
schema: z
.object({ ids: z.array(z.string()).min(1).max(200) })
.meta({ id: "RestoreLabResultsRequest" }),
},
},
},
responses: {
"200": {
description: "Restore count.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ restored: z.number() }),
"RestoreLabResultsResponse",
),
},
},
},
...stdResponses,
},
},
},
};
+7
View File
@@ -9,6 +9,7 @@ export const labKeys = {
labResults: () => ["lab-results"] as const,
labResultsList: (params: {
biomarkerId?: string | undefined;
analyte: string | undefined;
panel: string | undefined;
from: string | undefined;
@@ -19,6 +20,7 @@ export const labKeys = {
[
"lab-results",
"list",
params.biomarkerId ?? null,
params.analyte ?? null,
params.panel ?? null,
params.from ?? null,
@@ -26,4 +28,9 @@ export const labKeys = {
params.page,
params.sortDir,
] as const,
// v1.18.1 — user-scoped Biomarker catalog. The list feeds the picker and
// the manager; both invalidate `biomarkers()` after a mutation.
biomarkers: () => ["biomarkers"] as const,
biomarkerDetail: (id: string) => ["biomarkers", "detail", id] as const,
};
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "../biomarkers";
describe("createBiomarkerSchema", () => {
it("accepts a minimal marker (name + unit only)", () => {
const r = createBiomarkerSchema.safeParse({ name: "LDL", unit: "mg/dL" });
expect(r.success).toBe(true);
});
it("accepts both bounds when low <= high", () => {
const r = createBiomarkerSchema.safeParse({
name: "Fasting glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
});
expect(r.success).toBe(true);
});
it("accepts an open-ended upper bound (lower omitted)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
upperBound: 116,
});
expect(r.success).toBe(true);
});
it("rejects a transposed range (low > high)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
lowerBound: 200,
upperBound: 100,
});
expect(r.success).toBe(false);
});
it("rejects an empty name", () => {
const r = createBiomarkerSchema.safeParse({ name: " ", unit: "mg/dL" });
expect(r.success).toBe(false);
});
it("normalises an empty context to undefined", () => {
const r = createBiomarkerSchema.safeParse({
name: "TSH",
unit: "mIU/L",
context: "",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.context).toBeUndefined();
});
});
describe("updateBiomarkerSchema", () => {
it("accepts a partial edit", () => {
const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" });
expect(r.success).toBe(true);
});
it("accepts an explicit null to clear a bound", () => {
const r = updateBiomarkerSchema.safeParse({ lowerBound: null });
expect(r.success).toBe(true);
if (r.success) expect(r.data.lowerBound).toBeNull();
});
it("still rejects a transposed range on update", () => {
const r = updateBiomarkerSchema.safeParse({
lowerBound: 10,
upperBound: 5,
});
expect(r.success).toBe(false);
});
});
@@ -75,6 +75,32 @@ describe("createLabResultSchema", () => {
expect(r.success).toBe(true);
});
it("accepts a structured entry (biomarkerId, no analyte/unit)", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects an entry with neither biomarkerId nor analyte", () => {
const r = createLabResultSchema.safeParse({
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a free-text entry missing the unit", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("normalises an empty-string panel to undefined", () => {
const r = createLabResultSchema.safeParse({
analyte: "TSH",
+89
View File
@@ -0,0 +1,89 @@
/**
* v1.18.1 — user-scoped Biomarker catalog validation.
*
* A `Biomarker` defines a lab marker ONCE: canonical name, unit, reference
* bounds, and an optional context note. Recording a value later is just
* picking the biomarker — the unit and reference range are resolved
* server-side from the catalog row, never re-entered (ending the per-row
* free-text divergence: "LDL" vs "ldl", `mg/dL` vs `mg/dl`).
*
* The reference window is the lab/sex/age-dependent "normal" range; the
* server's neutral range verdict (`classifyReferenceRange`) is computed
* against these bounds. Both are independently optional (a marker may report
* only an upper bound, e.g. LDL < 116); when both are present the lower must
* not exceed the upper.
*/
import { z } from "zod/v4";
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
const upperBound = z.number().finite().optional();
const optionalContext = z
.string()
.trim()
.max(2000)
.optional()
// An empty / whitespace-only context normalises to "no context" so a
// blank field clears rather than stores an empty string.
.transform((v) => (v && v.length > 0 ? v : undefined));
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
.or(z.literal("").transform(() => undefined));
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
lowerBound,
upperBound,
context: optionalContext,
panel: optionalPanel,
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.upperBound === undefined ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
/**
* Update schema: every field optional (partial edit). `context` / `panel` /
* a bound accept an explicit `null` to clear the stored value — distinct from
* an omitted key, which leaves the column untouched.
*/
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
panel: optionalPanel.or(z.null()),
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.lowerBound === null ||
d.upperBound === undefined ||
d.upperBound === null ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type UpdateBiomarkerInput = z.infer<typeof updateBiomarkerSchema>;
+26 -3
View File
@@ -68,17 +68,38 @@ const optionalNote = z
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
* from the biomarker server-side, so `analyte` / `unit` may be omitted (the
* route fills them from the catalog row). A free-text reading without a
* catalog link stays valid (quick-capture / legacy path).
*/
const biomarkerId = z.string().trim().min(1).max(64).optional();
export const createLabResultSchema = z
.object({
biomarkerId,
panel: optionalPanel,
analyte: requiredText(120),
analyte: requiredText(120).optional(),
value: z.number().finite(),
unit: requiredText(40),
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
takenAt: takenAtField,
note: optionalNote,
})
// Either a catalog link OR a free-text analyte must be present.
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {
message: "Either biomarkerId or analyte is required",
path: ["analyte"],
})
// Without a catalog link the unit is required (the catalog otherwise
// supplies it server-side).
.refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, {
message: "unit is required when no biomarkerId is given",
path: ["unit"],
})
.refine(
(d) =>
d.referenceLow === undefined ||
@@ -123,8 +144,10 @@ export const updateLabResultSchema = z
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
/** List query: filter by analyte (exact) + date range, paginate. */
/** List query: filter by biomarker / analyte (exact) + date range, paginate. */
export const listLabResultsSchema = z.object({
// v1.18.1 — filter to one catalog marker's readings (the detail chart feed).
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
from: z