diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts new file mode 100644 index 000000000..829f88498 --- /dev/null +++ b/src/app/api/labs/[id]/route.ts @@ -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 = {}; + 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 }); + }, +); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts new file mode 100644 index 000000000..a80ff3f41 --- /dev/null +++ b/src/app/api/labs/route.ts @@ -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); +} diff --git a/src/lib/labs/store.ts b/src/lib/labs/store.ts new file mode 100644 index 000000000..b50ab1e05 --- /dev/null +++ b/src/lib/labs/store.ts @@ -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 (`"."`) 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 { + const ciphertext = encrypt(plaintext); + const encoded = Buffer.from(ciphertext, "utf8"); + // Prisma `Bytes` maps to `Uint8Array`; 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; +} diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d864a42bd..7a8e73804 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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, }; /** diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts new file mode 100644 index 000000000..374b59753 --- /dev/null +++ b/src/lib/query-keys/labs.ts @@ -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, +}; diff --git a/src/lib/validations/__tests__/labs.test.ts b/src/lib/validations/__tests__/labs.test.ts new file mode 100644 index 000000000..712ce8328 --- /dev/null +++ b/src/lib/validations/__tests__/labs.test.ts @@ -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"); + }); +}); diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts new file mode 100644 index 000000000..fde18bc7c --- /dev/null +++ b/src/lib/validations/labs.ts @@ -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; + +/** + * 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; + +/** 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; + +/** + * 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"; +}