mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"]> = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user