feat(labs): add structured lab-result store with /api/labs CRUD

Record discrete biomarker readings (HbA1c, LDL, ferritin, TSH, …) with
their value, unit, reference range and sample date. The store pairs with
the annual blood-panel reminder, which until now had nowhere to record
the result it reminds the user to obtain.

The list/create/update/delete endpoints narrow userId from the session,
build the write data field-by-field, soft-delete via deletedAt, and
encrypt the optional note at rest. A neutral three-state reference-range
classifier (in-range / below / above / unknown) is computed
server-side so every surface renders the same calm verdict.

findLatestLabResultForAnalytes exposes the read point the blood-panel
reminder consults to mark itself satisfied, without coupling the two.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:39:16 +02:00
1 parent 111fa7a323
commit 271cc0dcfa
7 files changed
+825

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
classifyReferenceRange,
updateLabResultSchema,
} from "@/lib/validations/labs";
/**
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
*
* GET returns the row including its decrypted note (the detail view is the
* one place the plaintext note surfaces). PUT applies a partial edit
* (`data` built field-by-field; an explicit `null` clears `panel` / `note` /
* a reference bound, an omitted key leaves it untouched). DELETE soft-deletes
* by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed).
*/
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
});
if (!row || row.userId !== user.id) {
return apiError("Lab result not found", 404);
}
annotate({ action: { name: "labs.get" }, meta: { labResultId: id } });
return apiSuccess({
id: row.id,
panel: row.panel,
analyte: row.analyte,
value: row.value,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
note: row.noteEncrypted
? decryptNoteFromBytes(row.noteEncrypted)
: null,
rangeStatus: classifyReferenceRange(
row.value,
row.referenceLow,
row.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
},
);
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Lab result not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateLabResultSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, labResultId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, labResultId: id }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const d = parsed.data;
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
const data: Record<string, unknown> = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
if (d.value !== undefined) data.value = d.value;
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
if (d.note !== undefined) {
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
const updated = await prisma.labResult.update({ where: { id }, data });
await auditLog("labResult.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: id },
});
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
return apiSuccess({
id: updated.id,
panel: updated.panel,
analyte: updated.analyte,
value: updated.value,
unit: updated.unit,
referenceLow: updated.referenceLow,
referenceHigh: updated.referenceHigh,
takenAt: updated.takenAt.toISOString(),
source: updated.source,
hasNote: updated.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
updated.value,
updated.referenceLow,
updated.referenceHigh,
),
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Lab result not found", 404);
}
// Soft-delete: stamp `deletedAt`. Every read filters `deletedAt: null`,
// so the row is invisible from here on. A re-delete is idempotent.
await prisma.labResult.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("labResult.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: id, analyte: existing.analyte },
});
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
return apiSuccess({ deleted: true });
},
);
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
} from "@/lib/validations/labs";
/**
* v1.17.1 — structured lab-result store (`/api/labs`).
*
* GET lists the caller's live results with optional analyte / panel / date
* filters. POST records a single reading. `userId` is always narrowed from
* the session — never a body field — and the write `data` object is built
* field-by-field (no mass assignment). The free-text note, when present, is
* AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write.
*/
// The serialised row never echoes the encrypted note bytes back; the badge
// status is computed server-side so the client renders a coherent, neutral
// in/out-of-range verdict without re-deriving the rule.
function serialiseLabResult(row: {
id: string;
panel: string | null;
analyte: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: Date;
source: string;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
panel: row.panel,
analyte: row.analyte,
value: row.value,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
row.value,
row.referenceLow,
row.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listLabResultsSchema.safeParse(params);
if (!parsed.success) {
annotate({
action: { name: "labs.list.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
const where = {
userId: user.id,
deletedAt: null,
...(analyte && { analyte }),
...(panel && { panel }),
...(from || to
? {
takenAt: {
...(from && { gte: from }),
...(to && { lte: to }),
},
}
: {}),
};
const [rows, total] = await Promise.all([
prisma.labResult.findMany({
where,
orderBy: { takenAt: sortDir },
take: limit,
skip: offset,
}),
prisma.labResult.count({ where }),
]);
annotate({
action: { name: "labs.list" },
meta: { total, limit, offset },
});
return apiSuccess({
results: rows.map(serialiseLabResult),
meta: { total, limit, offset },
});
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult));
async function postLabResult(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createLabResultSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
// Free-text `analyte` / `unit` / `note` could land verbatim in a Zod
// issue message — strip values from the audit-ledger breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } =
parsed.data;
// Field-by-field assignment — never spread `parsed.data`. `source` is
// hardcoded "MANUAL" for this user-facing path; import paths set their own.
const created = await prisma.labResult.create({
data: {
userId: user.id,
panel: panel ?? null,
analyte,
value,
unit,
referenceLow: referenceLow ?? null,
referenceHigh: referenceHigh ?? null,
takenAt,
source: "MANUAL",
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
await auditLog("labResult.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: created.id, analyte: created.analyte },
});
annotate({
action: { name: "labs.create" },
meta: { labResultId: created.id },
});
return apiSuccess(serialiseLabResult(created), 201);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* v1.17.1 — server-side helpers for the structured lab-result store.
*
* Two concerns live here:
*
* 1. The AES-256-GCM ↔ `Bytes` codec for `LabResult.noteEncrypted`. The
* free-text note 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.
*
* 2. `findLatestLabResultForAnalytes` — the read point the Vorsorge
* annual-blood-panel reminder consults to decide whether a panel was
* recorded inside its lead-time window. Exposed here, NOT coupled to
* the reminder's code, so the reminder can mark itself satisfied
* without this module taking a dependency on it.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
// Prisma `Bytes` maps to `Uint8Array<ArrayBuffer>`; allocate a fresh
// ArrayBuffer-backed view so the structural type stays stable.
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a stored `Bytes` note back to plaintext. Throws on a bad key id. */
export function decryptNoteFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
/**
* Latest live (non-tombstoned) lab result for any of the given analytes,
* recorded at or after `since`. Returns the single most-recent matching
* row by `takenAt`, or `null` when no matching result exists in the window.
*
* Case-insensitive analyte match so "HbA1c" / "hba1c" resolve the same — a
* lab report's casing is not the user's concern. The Vorsorge reminder
* passes the analytes that satisfy an annual blood panel (e.g. an HbA1c or
* a lipid marker) plus the reminder's window start; a non-null return means
* the panel was recorded and the reminder can mark itself satisfied.
*/
export async function findLatestLabResultForAnalytes(
userId: string,
analytes: string[],
since: Date,
): Promise<{ id: string; analyte: string; takenAt: Date } | null> {
if (analytes.length === 0) return null;
const candidates = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
takenAt: { gte: since },
// Prisma has no case-insensitive `in`; match each analyte
// case-insensitively and let the DB OR them together.
OR: analytes.map((a) => ({
analyte: { equals: a, mode: "insensitive" as const },
})),
},
orderBy: { takenAt: "desc" },
take: 1,
select: { id: true, analyte: true, takenAt: true },
});
return candidates[0] ?? null;
}
+2
View File
@@ -21,6 +21,7 @@ import { cycleKeys } from "./cycle";
import { dashboardKeys } from "./dashboard";
import { insightsKeys } from "./insights";
import { integrationKeys } from "./integrations";
import { labKeys } from "./labs";
import { measurementKeys } from "./measurements";
import { medicationKeys } from "./medications";
import { moodKeys } from "./mood";
@@ -40,6 +41,7 @@ export const queryKeys = {
...workoutKeys,
...settingsKeys,
...cycleKeys,
...labKeys,
};
/**
+33
View File
@@ -0,0 +1,33 @@
/**
* v1.17.1 — TanStack Query keys for the structured lab-result store.
*
* `labResultsList` bakes the filter + pagination shape into the key so a
* filter change refetches without a same-key cache collision (the project's
* queryKey-factory invariant).
*/
export const labKeys = {
labResults: () => ["lab-results"] as const,
labResultsList: (params: {
analyte: string | undefined;
panel: string | undefined;
from: string | undefined;
to: string | undefined;
page: number;
sortDir: string;
}) =>
[
"lab-results",
"list",
params.analyte ?? null,
params.panel ?? null,
params.from ?? null,
params.to ?? null,
params.page,
params.sortDir,
] as const,
/** Per-analyte trend series (≥2 values) shown inline on a grouped card. */
labAnalyteTrend: (analyte: string) =>
["lab-results", "trend", analyte] as const,
};
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect } from "vitest";
import {
classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
updateLabResultSchema,
} from "../labs";
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const RECENT = new Date(Date.now() - 60 * 60 * 1000).toISOString();
describe("createLabResultSchema", () => {
it("accepts a minimal valid reading", () => {
const r = createLabResultSchema.safeParse({
analyte: "HbA1c",
value: 5.4,
unit: "%",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("normalises a backdatable takenAt to a Date", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
takenAt: RECENT,
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.takenAt).toBeInstanceOf(Date);
});
it("rejects a future takenAt", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
takenAt: FUTURE,
});
expect(r.success).toBe(false);
});
it("rejects an empty analyte", () => {
const r = createLabResultSchema.safeParse({
analyte: " ",
value: 1,
unit: "%",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a transposed reference range (low > high)", () => {
const r = createLabResultSchema.safeParse({
analyte: "Ferritin",
value: 50,
unit: "ng/mL",
referenceLow: 300,
referenceHigh: 30,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("accepts a single (upper-only) reference bound", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
referenceHigh: 116,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("normalises an empty-string panel to undefined", () => {
const r = createLabResultSchema.safeParse({
analyte: "TSH",
value: 2,
unit: "mIU/L",
panel: "",
takenAt: RECENT,
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.panel).toBeUndefined();
});
it("rejects a non-finite value", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: Number.POSITIVE_INFINITY,
unit: "mg/dL",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
});
describe("updateLabResultSchema", () => {
it("accepts an explicit null to clear panel + note", () => {
const r = updateLabResultSchema.safeParse({ panel: null, note: null });
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.panel).toBeNull();
expect(r.data.note).toBeNull();
}
});
it("accepts a partial value-only edit", () => {
const r = updateLabResultSchema.safeParse({ value: 5.6 });
expect(r.success).toBe(true);
});
it("still enforces the range order when both bounds are supplied", () => {
const r = updateLabResultSchema.safeParse({
referenceLow: 10,
referenceHigh: 1,
});
expect(r.success).toBe(false);
});
});
describe("listLabResultsSchema", () => {
it("defaults limit/offset/sortDir", () => {
const r = listLabResultsSchema.safeParse({});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.limit).toBe(100);
expect(r.data.offset).toBe(0);
expect(r.data.sortDir).toBe("desc");
}
});
it("caps limit at 500", () => {
const r = listLabResultsSchema.safeParse({ limit: "9999" });
expect(r.success).toBe(false);
});
});
describe("classifyReferenceRange", () => {
it("returns unknown when no bounds are given", () => {
expect(classifyReferenceRange(5, null, null)).toBe("unknown");
expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown");
});
it("returns in-range inside both bounds", () => {
expect(classifyReferenceRange(5, 4, 6)).toBe("in-range");
});
it("treats the bounds as inclusive", () => {
expect(classifyReferenceRange(4, 4, 6)).toBe("in-range");
expect(classifyReferenceRange(6, 4, 6)).toBe("in-range");
});
it("returns below when under the low bound", () => {
expect(classifyReferenceRange(3.9, 4, 6)).toBe("below");
});
it("returns above when over the high bound", () => {
expect(classifyReferenceRange(6.1, 4, 6)).toBe("above");
});
it("classifies against a single upper bound", () => {
expect(classifyReferenceRange(120, null, 116)).toBe("above");
expect(classifyReferenceRange(100, null, 116)).toBe("in-range");
});
it("classifies against a single lower bound", () => {
expect(classifyReferenceRange(8, 10, null)).toBe("below");
expect(classifyReferenceRange(12, 10, null)).toBe("in-range");
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* v1.17.1 — structured lab-result store.
*
* A minimal, generic `LabResult` capture path: a self-hoster records a
* biomarker reading (HbA1c, LDL, ferritin, TSH, …) with its value, unit,
* the lab's reference range, and the sample date. Pairs with the v1.17.1
* Vorsorge annual-blood-panel reminder, which otherwise has nowhere to
* record the result it reminds the user to obtain.
*
* The model is intentionally free-form on `analyte` + `unit` (no closed
* LOINC enum): a lab report prints whatever name and unit it likes, and a
* forced taxonomy would reject real-world values. The optional `panel`
* groups analytes that arrived together (e.g. "Großes Blutbild").
*/
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* Far-past floor for a lab sample. A self-hoster legitimately backdates an
* old paper report, so the window is generous (50 years) but still rejects
* epoch-zero / corrupted-import instants via the shared 1900 floor.
*/
const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */
const takenAtField = validateEntryInstant(
z
.string()
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
{
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
pastMessage: "Sample date is too far in the past",
},
);
/** Trimmed, bounded free-text field with a min-length guard. */
const requiredText = (max: number) =>
z
.string()
.trim()
.min(1)
.max(max);
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
// An empty-string panel is normalised to "no panel" so the grouping
// stays NULL-distinct rather than carrying a blank label.
.or(z.literal("").transform(() => undefined));
const optionalNote = z
.string()
.trim()
.max(2000)
.optional()
.or(z.literal("").transform(() => undefined));
/**
* Reference bounds are independently optional (a lab may report only an
* upper bound, e.g. LDL < 116). When BOTH are present the low bound must
* not exceed the high bound — a transposed range is a client error.
*/
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
export const createLabResultSchema = z
.object({
panel: optionalPanel,
analyte: requiredText(120),
value: z.number().finite(),
unit: requiredText(40),
referenceLow,
referenceHigh,
takenAt: takenAtField,
note: optionalNote,
})
.refine(
(d) =>
d.referenceLow === undefined ||
d.referenceHigh === undefined ||
d.referenceLow <= d.referenceHigh,
{
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
);
export type CreateLabResultInput = z.infer<typeof createLabResultSchema>;
/**
* Update schema: every field optional (partial edit). `panel` and `note`
* accept an explicit `null` to clear the stored value — distinct from an
* omitted key, which leaves the column untouched.
*/
export const updateLabResultSchema = z
.object({
panel: optionalPanel.or(z.null()),
analyte: requiredText(120).optional(),
value: z.number().finite().optional(),
unit: requiredText(40).optional(),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
takenAt: takenAtField.optional(),
note: optionalNote.or(z.null()),
})
.refine(
(d) =>
d.referenceLow === undefined ||
d.referenceLow === null ||
d.referenceHigh === undefined ||
d.referenceHigh === null ||
d.referenceLow <= d.referenceHigh,
{
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
);
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
/** List query: filter by analyte (exact) + date range, paginate. */
export const listLabResultsSchema = z.object({
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
from: z
.string()
.datetime()
.transform((s) => new Date(s))
.optional(),
to: z
.string()
.datetime()
.transform((s) => new Date(s))
.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
sortDir: z.enum(["asc", "desc"]).default("desc"),
});
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
/**
* Reference-range classification. Deliberately a three-state, NEUTRAL
* verdict — the badge that renders it must stay calm and informative, NOT
* alarming (no red "out of range" tint). `"unknown"` when the lab reported
* no usable bounds.
*
* Bounds are treated as inclusive: a value exactly on the reference limit
* reads as in-range, matching how labs print "≤" / "≥" reference notation.
*/
export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown";
export function classifyReferenceRange(
value: number,
referenceLow: number | null | undefined,
referenceHigh: number | null | undefined,
): ReferenceRangeStatus {
const hasLow = referenceLow !== null && referenceLow !== undefined;
const hasHigh = referenceHigh !== null && referenceHigh !== undefined;
if (!hasLow && !hasHigh) return "unknown";
if (hasLow && value < (referenceLow as number)) return "below";
if (hasHigh && value > (referenceHigh as number)) return "above";
return "in-range";
}