mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
commit
cc32e2adf7
40 files changed
+4403
-360
No files matched your search
@@ -74,6 +74,8 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/sleep",
|
||||
"src/app/api/workouts",
|
||||
"src/app/api/labs",
|
||||
// v1.18.1 — the user-scoped Biomarker catalog backs the Labs feature.
|
||||
"src/app/api/biomarkers",
|
||||
"src/app/api/cycle",
|
||||
"src/app/api/gamification",
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
|
||||
@@ -129,6 +131,14 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/workouts/batch/route.ts",
|
||||
"src/app/api/labs/route.ts",
|
||||
"src/app/api/labs/[id]/route.ts",
|
||||
// v1.18.1 — the lab-result delete-Undo restore endpoint and the
|
||||
// user-scoped Biomarker catalog CRUD share the LabResult data-layer
|
||||
// reasoning: the labs module gates the SURFACES (the Labs page), not the
|
||||
// row store. A synced / pre-existing reading and its catalog definition
|
||||
// must survive a disabled module so re-enabling reveals a complete history.
|
||||
"src/app/api/labs/restore/route.ts",
|
||||
"src/app/api/biomarkers/route.ts",
|
||||
"src/app/api/biomarkers/[id]/route.ts",
|
||||
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
|
||||
// Static FHIR CapabilityStatement — server metadata, no user data.
|
||||
"src/app/api/fhir/metadata/route.ts",
|
||||
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user