mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
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 { 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 {
|
|
createLabResultSchema,
|
|
listLabResultsSchema,
|
|
} from "@/lib/validations/labs";
|
|
|
|
/**
|
|
* v1.17.1 — structured lab-result store (`/api/labs`).
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
/** 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) => {
|
|
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 { 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
|
|
? {
|
|
takenAt: {
|
|
...(from && { gte: from }),
|
|
...(to && { lte: to }),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const [rows, total] = await Promise.all([
|
|
prisma.labResult.findMany({
|
|
where,
|
|
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 }),
|
|
]);
|
|
|
|
annotate({
|
|
action: { name: "labs.list" },
|
|
meta: { total, limit, offset },
|
|
});
|
|
|
|
return apiSuccess({
|
|
results: rows.map((row) =>
|
|
serialiseLabResult(row, toResolved(row.biomarker)),
|
|
),
|
|
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 {
|
|
biomarkerId,
|
|
panel,
|
|
analyte,
|
|
value,
|
|
unit,
|
|
referenceLow,
|
|
referenceHigh,
|
|
takenAt,
|
|
note,
|
|
} = parsed.data;
|
|
|
|
// 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,
|
|
biomarkerId: biomarker?.id ?? null,
|
|
panel: biomarker ? biomarker.panel : (panel ?? null),
|
|
analyte: biomarker ? biomarker.name : (analyte as string),
|
|
value,
|
|
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,
|
|
},
|
|
});
|
|
|
|
await auditLog("labResult.create", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { labResultId: created.id },
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "labs.create" },
|
|
meta: { labResultId: created.id, structured: biomarker !== null },
|
|
});
|
|
|
|
// v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just
|
|
// landed; resolve the user's free-text "annual blood panel" reminders
|
|
// now rather than waiting on the 15-min cron. Fire-and-forget.
|
|
void enqueueReminderSatisfy(user.id).catch(() => {});
|
|
|
|
return apiSuccess(serialiseLabResult(created, biomarker), 201);
|
|
}
|