From c6be0778dccbe2996c1b86c3bbb29c7d2da59036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 12:10:49 +0200 Subject: [PATCH] Let a delegate enter a lab result and the marker it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/labs and POST /api/biomarkers resolve the record. The free-text lab path mints its biomarker under the record's catalogue rather than the caller's — a result added to somebody's record whose marker landed on the helper's account would leave the owner with a reading they cannot chart. Adding a marker the record already tracks stays the ordinary 409 from the same per-account uniqueness the owner would hit themselves. The PUT and DELETE on the sibling routes are unchanged and still refuse under a switch. Both validation breadcrumbs move to auditLog(), the only writer that stamps the acting account. --- .../__tests__/error-contract.test.ts | 5 ++++ src/app/api/biomarkers/route.ts | 26 ++++++++--------- .../api/labs/__tests__/error-contract.test.ts | 5 ++++ src/app/api/labs/route.ts | 28 ++++++++++--------- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/app/api/biomarkers/__tests__/error-contract.test.ts b/src/app/api/biomarkers/__tests__/error-contract.test.ts index a594a6408..044cf9d62 100644 --- a/src/app/api/biomarkers/__tests__/error-contract.test.ts +++ b/src/app/api/biomarkers/__tests__/error-contract.test.ts @@ -34,6 +34,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; const SESSION = { @@ -53,6 +54,10 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.x — the create arm's validation breadcrumb goes through the + // `auditLog` helper, the only writer that stamps `actorUserId`. The route + // chains `.catch()` onto it, so the stub has to return a promise. + vi.mocked(auditLog).mockResolvedValue(undefined); }); describe("biomarker create error contract", () => { diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 256f17ec5..3880e9c1c 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -91,7 +91,11 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker)); async function postBiomarker(request: NextRequest) { - const { user } = await requireAuth(); + // v1.36.x — a delegated write: the marker joins the record's catalogue. The + // duplicate pre-check and the `@@unique([userId, name])` backstop both scope + // to the same resolved id, so a delegate adding a marker the owner already + // tracks gets the ordinary 409 rather than a second copy. + const { user } = await requireRecordAuth("write"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -107,17 +111,13 @@ async function postBiomarker(request: NextRequest) { 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 */ - }); + // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + void auditLog("labs.biomarker.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "labs.biomarker.create.invalid", }); diff --git a/src/app/api/labs/__tests__/error-contract.test.ts b/src/app/api/labs/__tests__/error-contract.test.ts index 2e02fcd03..302325c71 100644 --- a/src/app/api/labs/__tests__/error-contract.test.ts +++ b/src/app/api/labs/__tests__/error-contract.test.ts @@ -50,6 +50,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { PUT } from "../[id]/route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; const SESSION = { @@ -93,6 +94,10 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.x — the create arm's validation breadcrumb goes through the + // `auditLog` helper, the only writer that stamps `actorUserId`. The route + // chains `.catch()` onto it, so the stub has to return a promise. + vi.mocked(auditLog).mockResolvedValue(undefined); }); describe("lab result error contract", () => { diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 43b099036..6fa45b9a0 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -139,7 +139,11 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult)); async function postLabResult(request: NextRequest) { - const { user } = await requireAuth(); + // v1.36.x — a delegated write. The free-text path may MINT a biomarker, and + // it mints it under `user.id`: the marker joins the record's catalogue, not + // the caller's, which is the only reading of "add a result to your record" + // that leaves the owner with a usable catalogue afterwards. + const { user } = await requireRecordAuth("write"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -157,17 +161,15 @@ async function postLabResult(request: NextRequest) { 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 */ - }); + // v1.36.x — through `auditLog()`, the only writer that stamps + // `actorUserId`. A bare `prisma.auditLog.create` files a delegate's + // malformed payload as if the owner had typed it. + void auditLog("labs.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "labs.create.invalid", });