merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill

# Conflicts:
#	src/app/api/labs/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:09 +02:00
commit cc32e2adf7
40 files changed
+4403 -360

No files matched your search

+50 -45
View File
@@ -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 } });
+73
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -12,10 +13,13 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
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";
@@ -23,49 +27,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) => {
@@ -81,11 +67,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
@@ -104,6 +92,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 }),
]);
@@ -114,7 +114,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 },
});
});
@@ -154,20 +156,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,
@@ -177,12 +214,12 @@ 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 },
});
// v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just
@@ -190,5 +227,5 @@ async function postLabResult(request: NextRequest) {
// now rather than waiting on the 15-min cron. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
return apiSuccess(serialiseLabResult(created), 201);
return apiSuccess(serialiseLabResult(created, biomarker), 201);
}