mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(labs): biomarker catalog with server-resolved reference ranges
Add a user-scoped Biomarker catalog so a marker is defined once — name, unit, lower/upper bound, optional encrypted context, panel — and every later reading just links it. New /api/biomarkers CRUD plus a structured /api/labs create path: a biomarkerId resolves the unit + reference range server-side, so the list and detail DTOs carry the resolved values and the client never recomputes the range. Lab reads include the linked marker and fall back to the legacy free-text fields for unlinked rows. Add a lab-result restore endpoint for the delete-Undo affordance, a shared serialiser, a static common-panel seed list, and tests for catalog validation, structured-entry resolution, and the context codec.
This commit is contained in:
1 parent
bee7295b19
commit
e9037a0d92
16 files changed
+1194
-104
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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 {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
|
||||
/**
|
||||
* v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`).
|
||||
*
|
||||
* PUT applies a partial edit (`data` built field-by-field; an explicit `null`
|
||||
* clears `context` / `panel` / a bound, an omitted key leaves it untouched).
|
||||
* DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on
|
||||
* `LabResult.biomarkerId` unlinks every reading without losing it (the row
|
||||
* keeps its legacy `analyte` / `unit` historical fields). Cross-user rows
|
||||
* surface as 404 (existence sealed).
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
: null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.get" },
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.biomarker.findFirst({ where: { id } });
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateBiomarkerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.update.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
|
||||
});
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.biomarker.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const d = parsed.data;
|
||||
|
||||
// A rename must not collide with another of the caller's markers.
|
||||
if (d.name !== undefined && d.name !== existing.name) {
|
||||
const clash = await prisma.biomarker.findFirst({
|
||||
where: { userId: user.id, name: d.name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (clash) {
|
||||
return apiError("A biomarker with this name already exists", 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `context` / `panel` / a bound clears it.
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.name !== undefined) data.name = d.name;
|
||||
if (d.unit !== undefined) data.unit = d.unit;
|
||||
if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
|
||||
if (d.upperBound !== undefined) data.upperBound = d.upperBound;
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.context !== undefined) {
|
||||
data.contextEncrypted = d.context
|
||||
? encryptContextToBytes(d.context)
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await prisma.biomarker.update({ where: { id }, data });
|
||||
|
||||
await auditLog("biomarker.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { biomarkerId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.update" },
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(updated));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.biomarker.findFirst({ where: { id } });
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
}
|
||||
|
||||
// Hard delete — the `onDelete: SetNull` FK unlinks every reading rather
|
||||
// than cascading it away. The readings keep their legacy `analyte` /
|
||||
// `unit` so the history survives the catalog edit.
|
||||
await prisma.biomarker.delete({ where: { id } });
|
||||
|
||||
await auditLog("biomarker.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { biomarkerId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.delete" },
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
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 { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
|
||||
/**
|
||||
* v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`).
|
||||
*
|
||||
* The catalog is the Labs feature's primary object: a marker is defined ONCE
|
||||
* (name, unit, reference bounds, optional context) and every later reading
|
||||
* just picks it. GET lists the caller's markers; POST defines a new one.
|
||||
* `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
|
||||
* optional context note is AES-256-GCM encrypted into the `contextEncrypted`
|
||||
* Bytes column before write. The `@@unique([userId, name])` index means there
|
||||
* is never a second "LDL" definition for one user.
|
||||
*/
|
||||
|
||||
// The encrypted context bytes are never echoed back; `hasContext` flags
|
||||
// presence and the single-resource GET returns the decrypted text.
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
: null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rows = await prisma.biomarker.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: [{ name: "asc" }],
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.list" },
|
||||
meta: { total: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
|
||||
|
||||
async function postBiomarker(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createBiomarkerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.create.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.biomarker.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
|
||||
|
||||
// Reject a duplicate name up front so the client sees a clean 409 rather
|
||||
// than a Prisma unique-constraint 500. The `@@unique([userId, name])` index
|
||||
// is the structural backstop.
|
||||
const existing = await prisma.biomarker.findFirst({
|
||||
where: { userId: user.id, name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
return apiError("A biomarker with this name already exists", 409);
|
||||
}
|
||||
|
||||
// Field-by-field assignment — never spread `parsed.data`.
|
||||
const created = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
unit,
|
||||
lowerBound: lowerBound ?? null,
|
||||
upperBound: upperBound ?? null,
|
||||
panel: panel ?? null,
|
||||
contextEncrypted: context ? encryptContextToBytes(context) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("biomarker.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { biomarkerId: created.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.create" },
|
||||
meta: { biomarkerId: created.id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(created), 201);
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
serialiseLabResultDetail,
|
||||
} from "@/lib/labs/serialise";
|
||||
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
updateLabResultSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
import { updateLabResultSchema } from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
|
||||
@@ -26,10 +28,36 @@ import {
|
||||
* (`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).
|
||||
*
|
||||
* v1.18.1 — when the row links a `Biomarker`, the response resolves the
|
||||
* canonical name / unit / range from the catalog (server-authoritative). The
|
||||
* legacy per-row free-text fields stay editable for an unlinked row.
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const biomarkerSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
} as const;
|
||||
|
||||
function toResolved(
|
||||
bm: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
} | null,
|
||||
): ResolvedBiomarker | null {
|
||||
return bm ?? null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -37,6 +65,7 @@ export const GET = apiHandler(
|
||||
|
||||
const row = await prisma.labResult.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: { biomarker: { select: biomarkerSelect } },
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Lab result not found", 404);
|
||||
@@ -44,27 +73,13 @@ export const GET = apiHandler(
|
||||
|
||||
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,
|
||||
return apiSuccess(
|
||||
serialiseLabResultDetail(
|
||||
row,
|
||||
toResolved(row.biomarker),
|
||||
row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null,
|
||||
),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,6 +127,8 @@ export const PUT = apiHandler(
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `panel` / `note` / a reference bound clears it.
|
||||
// A reading linked to a catalog marker is edited via VALUE / takenAt /
|
||||
// note here; its name / unit / range stay resolved from the biomarker.
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.analyte !== undefined) data.analyte = d.analyte;
|
||||
@@ -124,7 +141,11 @@ export const PUT = apiHandler(
|
||||
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.labResult.update({ where: { id }, data });
|
||||
const updated = await prisma.labResult.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { biomarker: { select: biomarkerSelect } },
|
||||
});
|
||||
|
||||
await auditLog("labResult.update", {
|
||||
userId: user.id,
|
||||
@@ -134,25 +155,9 @@ export const PUT = apiHandler(
|
||||
|
||||
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(),
|
||||
});
|
||||
return apiSuccess(
|
||||
serialiseLabResult(updated, toResolved(updated.biomarker)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -178,7 +183,7 @@ export const DELETE = apiHandler(
|
||||
await auditLog("labResult.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { labResultId: id, analyte: existing.analyte },
|
||||
details: { labResultId: id },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* `POST /api/labs/restore` — un-tombstone for the lab-history delete-Undo
|
||||
* affordance (v1.18.1).
|
||||
*
|
||||
* Body: `{ ids: string[] }` — 1..200 lab-result ids, scoped to the caller.
|
||||
*
|
||||
* Clears `deletedAt` on every owned, currently-tombstoned row in one
|
||||
* `updateMany`, so the reading re-surfaces in normal reads. A forged /
|
||||
* foreign / not-deleted id is a silent no-op (never a 404 existence leak):
|
||||
* the mutation `where` pins `userId` so it only ever touches the caller's
|
||||
* rows. Mirrors `/api/measurements/restore`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const restoreSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
|
||||
|
||||
async function postRestore(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`labs:restore:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many restore requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = restoreSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.restore.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
|
||||
// `userId`-scoped + `deletedAt != null`: a foreign / live id is skipped.
|
||||
const { count } = await prisma.labResult.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
|
||||
|
||||
return apiSuccess({ restored: count });
|
||||
}
|
||||
+93
-56
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -11,10 +12,13 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
} from "@/lib/labs/serialise";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
createLabResultSchema,
|
||||
listLabResultsSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
@@ -22,49 +26,31 @@ import {
|
||||
/**
|
||||
* 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.
|
||||
* GET lists the caller's live results with optional biomarker / 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.
|
||||
*
|
||||
* v1.18.1 — structured entry: when the body carries a `biomarkerId`, the row
|
||||
* links the user-scoped catalog marker and the response resolves its unit +
|
||||
* reference range FROM the biomarker (server-authoritative). The web + iOS
|
||||
* clients render the resolved DTO and never recompute.
|
||||
*/
|
||||
|
||||
// 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(),
|
||||
};
|
||||
/** Map the joined biomarker (or null) into the resolver's shape. */
|
||||
function toResolved(
|
||||
bm: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
} | null,
|
||||
): ResolvedBiomarker | null {
|
||||
return bm ?? null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -80,11 +66,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
|
||||
const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(biomarkerId && { biomarkerId }),
|
||||
...(analyte && { analyte }),
|
||||
...(panel && { panel }),
|
||||
...(from || to
|
||||
@@ -103,6 +91,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
orderBy: { takenAt: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
biomarker: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.labResult.count({ where }),
|
||||
]);
|
||||
@@ -113,7 +113,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
results: rows.map(serialiseLabResult),
|
||||
results: rows.map((row) =>
|
||||
serialiseLabResult(row, toResolved(row.biomarker)),
|
||||
),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
});
|
||||
@@ -153,20 +155,55 @@ async function postLabResult(request: NextRequest) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } =
|
||||
parsed.data;
|
||||
const {
|
||||
biomarkerId,
|
||||
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.
|
||||
// Structured-entry path: resolve the catalog marker (and verify ownership)
|
||||
// so the row's name + unit derive from it. A forged / foreign id is a 404.
|
||||
let biomarker: ResolvedBiomarker | null = null;
|
||||
if (biomarkerId) {
|
||||
const found = await prisma.biomarker.findFirst({
|
||||
where: { id: biomarkerId, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
},
|
||||
});
|
||||
if (!found) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
}
|
||||
biomarker = found;
|
||||
}
|
||||
|
||||
// Field-by-field assignment — never spread `parsed.data`. With a catalog
|
||||
// link the row stamps the resolved name/unit/range as historical truth
|
||||
// (so a later catalog edit does not silently rewrite a past reading) AND
|
||||
// keeps the FK; reads resolve the CURRENT catalog values via `serialise`.
|
||||
const created = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
panel: panel ?? null,
|
||||
analyte,
|
||||
biomarkerId: biomarker?.id ?? null,
|
||||
panel: biomarker ? biomarker.panel : (panel ?? null),
|
||||
analyte: biomarker ? biomarker.name : (analyte as string),
|
||||
value,
|
||||
unit,
|
||||
referenceLow: referenceLow ?? null,
|
||||
referenceHigh: referenceHigh ?? null,
|
||||
unit: biomarker ? biomarker.unit : (unit as string),
|
||||
referenceLow: biomarker ? biomarker.lowerBound : (referenceLow ?? null),
|
||||
referenceHigh: biomarker
|
||||
? biomarker.upperBound
|
||||
: (referenceHigh ?? null),
|
||||
takenAt,
|
||||
source: "MANUAL",
|
||||
noteEncrypted: note ? encryptNoteToBytes(note) : null,
|
||||
@@ -176,13 +213,13 @@ async function postLabResult(request: NextRequest) {
|
||||
await auditLog("labResult.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { labResultId: created.id, analyte: created.analyte },
|
||||
details: { labResultId: created.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.create" },
|
||||
meta: { labResultId: created.id },
|
||||
meta: { labResultId: created.id, structured: biomarker !== null },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseLabResult(created), 201);
|
||||
return apiSuccess(serialiseLabResult(created, biomarker), 201);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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