mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
111fa7a323
commit
271cc0dcfa
7 files changed
+825
No files matched your search
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user