mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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);
|
||||
}
|
||||
Reference in new issue
Block a user