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

@@ -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",
+190
View File
@@ -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 });
},
);
+151
View File
@@ -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);
}
+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);
}