From af14d0aa7e0fc297629dccc61d0db2ceea901839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:29:22 +0200 Subject: [PATCH] refactor(api): read the health-record route bodies through parseJsonBody Labs, biomarkers, custom metrics, cycle, anamnesis, illness, measurement reminders, the measurement edit, medication sub-routes, mood and mental health assessments still spelled out safeJson, safeParse and the 422 by hand. Each becomes one parseJsonBody call with the size cap, action, issue count, error code and status it had. The audit rows and the unstable-externalId breadcrumbs move into onInvalid, in the order they ran; where a breadcrumb reads the body, the wide-event annotate moves with it. A `body ?? {}` or a payload unwrapped from the body becomes `input`. The medication ingest route reads through the helper too; its Bearer resolution happens before the read and is unchanged. --- src/__tests__/baselines/route-safe-json.json | 31 +------ src/app/api/anamnesis/emergency/route.ts | 15 +--- src/app/api/anamnesis/facts/[id]/route.ts | 16 +--- src/app/api/anamnesis/facts/route.ts | 16 +--- src/app/api/biomarkers/[id]/route.ts | 50 +++++------ src/app/api/biomarkers/route.ts | 43 ++++----- .../[id]/entries/[entryId]/route.ts | 49 +++++----- .../api/custom-metrics/[id]/entries/route.ts | 40 ++++----- src/app/api/custom-metrics/[id]/route.ts | 55 ++++++------ src/app/api/custom-metrics/route.ts | 48 +++++----- src/app/api/cycle/day-logs/route.ts | 36 ++++---- src/app/api/cycle/period/route.ts | 38 ++++---- .../illness/episodes/[id]/resolve/route.ts | 22 ++--- src/app/api/ingest/medication/route.ts | 73 ++++++++------- src/app/api/labs/[id]/route.ts | 50 +++++------ src/app/api/labs/ocr/commit/route.ts | 52 +++++------ src/app/api/labs/route.ts | 50 +++++------ .../api/measurement-reminders/[id]/route.ts | 56 ++++++------ .../[id]/snooze/route.ts | 20 ++--- src/app/api/measurement-reminders/route.ts | 53 +++++------ src/app/api/measurements/[id]/route.ts | 61 ++++++------- .../medications/[id]/intake/import/route.ts | 90 +++++++++---------- .../schedule-revisions/[revisionId]/route.ts | 19 ++-- .../[id]/schedule-revisions/route.ts | 19 ++-- src/app/api/medications/extract/route.ts | 26 +++--- .../api/mental-health/assessments/route.ts | 37 ++++---- src/app/api/mood-entries/[id]/route.ts | 59 ++++++------ src/app/api/mood-entries/route.ts | 69 +++++++------- 28 files changed, 495 insertions(+), 698 deletions(-) diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index 3e1e6201b..3931f309f 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -1,7 +1,7 @@ { "$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.", - "count": 106, - "total": 106, + "count": 79, + "total": 79, "entries": { "app/api/admin/ai-settings/route.ts": 1, "app/api/admin/drain-per-sample-cumulative/route.ts": 1, @@ -13,9 +13,6 @@ "app/api/admin/settings/web-push-vapid/generate/route.ts": 1, "app/api/admin/users/[id]/reset-password/route.ts": 1, "app/api/ai/test/route.ts": 1, - "app/api/anamnesis/emergency/route.ts": 1, - "app/api/anamnesis/facts/[id]/route.ts": 1, - "app/api/anamnesis/facts/route.ts": 1, "app/api/auth/login/route.ts": 1, "app/api/auth/me/locale/route.ts": 1, "app/api/auth/me/mfa/disable/route.ts": 1, @@ -26,18 +23,10 @@ "app/api/auth/passkey/login-options/route.ts": 1, "app/api/auth/profile/route.ts": 1, "app/api/auth/refresh/route.ts": 1, - "app/api/biomarkers/[id]/route.ts": 1, - "app/api/biomarkers/route.ts": 1, "app/api/coach/about-me/questions/route.ts": 1, "app/api/coach/about-me/route.ts": 1, "app/api/consent/ai/route.ts": 1, - "app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 1, - "app/api/custom-metrics/[id]/entries/route.ts": 1, - "app/api/custom-metrics/[id]/route.ts": 1, - "app/api/custom-metrics/route.ts": 1, "app/api/cycle/day-logs/bulk/route.ts": 1, - "app/api/cycle/day-logs/route.ts": 1, - "app/api/cycle/period/route.ts": 1, "app/api/dashboard/chart-overlay-prefs/route.ts": 1, "app/api/dashboard/widgets/route.ts": 1, "app/api/dawarich/connect/route.ts": 1, @@ -51,9 +40,7 @@ "app/api/documents/inbound/[id]/summary/route.ts": 1, "app/api/fitbit/credentials/route.ts": 1, "app/api/google-health/credentials/route.ts": 1, - "app/api/illness/episodes/[id]/resolve/route.ts": 1, "app/api/import/route.ts": 1, - "app/api/ingest/medication/route.ts": 1, "app/api/insights/chat/messages/[id]/feedback/route.ts": 1, "app/api/insights/ecg/route.ts": 1, "app/api/insights/feedback/route.ts": 1, @@ -62,31 +49,17 @@ "app/api/insights/provider-chain/route.ts": 1, "app/api/integrations/healthkit/route.ts": 1, "app/api/internal/deploy-webhook/route.ts": 1, - "app/api/labs/[id]/route.ts": 1, - "app/api/labs/ocr/commit/route.ts": 1, "app/api/labs/ocr/extract/route.ts": 1, - "app/api/labs/route.ts": 1, - "app/api/measurement-reminders/[id]/route.ts": 1, - "app/api/measurement-reminders/[id]/snooze/route.ts": 1, - "app/api/measurement-reminders/route.ts": 1, - "app/api/measurements/[id]/route.ts": 1, "app/api/measurements/batch/route.ts": 1, "app/api/measurements/by-external-ids/route.ts": 1, "app/api/measurements/route.ts": 1, "app/api/medications/[id]/intake/[eventId]/route.ts": 1, - "app/api/medications/[id]/intake/import/route.ts": 1, "app/api/medications/[id]/intake/route.ts": 1, - "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 1, - "app/api/medications/[id]/schedule-revisions/route.ts": 1, - "app/api/medications/extract/route.ts": 1, "app/api/medications/intake/bulk/route.ts": 1, "app/api/medications/intake/route.ts": 1, "app/api/medications/layout/route.ts": 1, "app/api/medications/route.ts": 1, - "app/api/mental-health/assessments/route.ts": 1, - "app/api/mood-entries/[id]/route.ts": 1, "app/api/mood-entries/bulk/route.ts": 1, - "app/api/mood-entries/route.ts": 1, "app/api/mood/tags/layout/route.ts": 1, "app/api/nightscout/connect/route.ts": 1, "app/api/nutrients/batch/route.ts": 1, diff --git a/src/app/api/anamnesis/emergency/route.ts b/src/app/api/anamnesis/emergency/route.ts index faa8e1d1b..2247c7c36 100644 --- a/src/app/api/anamnesis/emergency/route.ts +++ b/src/app/api/anamnesis/emergency/route.ts @@ -1,12 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -31,13 +27,10 @@ export const GET = apiHandler(async () => { export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: rawBody, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, emergencyProfileUpdateSchema, { maxBytes: 16 * 1024, }); - if (error) return error; - - const parsed = emergencyProfileUpdateSchema.safeParse(rawBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const payload = await writeEmergencyProfile(user.id, parsed.data); await auditLog("anamnesis.emergency.update", { diff --git a/src/app/api/anamnesis/facts/[id]/route.ts b/src/app/api/anamnesis/facts/[id]/route.ts index 5551cd635..fc7f8f812 100644 --- a/src/app/api/anamnesis/facts/[id]/route.ts +++ b/src/app/api/anamnesis/facts/[id]/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -39,13 +34,10 @@ export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; - const { data: rawBody, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, factCorrectionSchema, { maxBytes: 8 * 1024, }); - if (error) return error; - - const parsed = factCorrectionSchema.safeParse(rawBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const current = await prisma.healthProfileFactRevision.findFirst({ where: { diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 5d912b023..262c7d1d9 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; @@ -41,13 +36,10 @@ export const GET = apiHandler(async () => { async function postFact(request: NextRequest): Promise { const { user } = await requireAuth(); - const { data: rawBody, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, factWriteSchema, { maxBytes: 8 * 1024, }); - if (error) return error; - - const parsed = factWriteSchema.safeParse(rawBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const now = new Date(); const validFrom = parsed.data.validFrom diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 17f4b07ca..864071182 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { destroyedDetails, @@ -122,34 +121,29 @@ export const PUT = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateBiomarkerSchema, { maxBytes: 16 * 1024, + action: "labs.biomarker.update.validation-failed", + countIssues: true, + meta: { biomarkerId: id }, + errorCode: "labs.biomarker.update.invalid", + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("labs.biomarker.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, biomarkerId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - 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, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("labs.biomarker.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, biomarkerId: id }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.biomarker.update.invalid", - }); - } + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index a68d39152..3dca738f1 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -77,31 +76,25 @@ async function postBiomarker(request: NextRequest) { // tracks gets the ordinary 409 rather than a second copy. const { user } = await requireRecordAuth("write", "labs"); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createBiomarkerSchema, { maxBytes: 16 * 1024, + action: "labs.biomarker.create.validation-failed", + countIssues: true, + errorCode: "labs.biomarker.create.invalid", + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // 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 */ + }); + }, }); - 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, - }); - // 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", - }); - } + if (!parsed.ok) return parsed.error; const { name, unit, lowerBound, upperBound, context, panel } = parsed.data; diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index eb3e9cfa1..2f1d171fe 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -49,33 +48,29 @@ export const PATCH = apiHandler( return apiError("Custom metric entry not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomMetricEntrySchema, { maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateCustomMetricEntrySchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.entry.update.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, entryId }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "custom-metric.entry.update.validation-failed", - details: JSON.stringify({ issues: auditIssues, entryId }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ + action: "custom-metric.entry.update.validation-failed", + countIssues: true, + meta: { entryId }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.entry.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, entryId }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 2e1832ad5..d4e9ea3c9 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -6,9 +6,9 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -117,29 +117,25 @@ async function postCustomMetricEntry( return apiError("Custom metric not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomMetricEntrySchema, { maxBytes: 16 * 1024, + action: "custom-metric.entry.create.validation-failed", + countIssues: true, + meta: { customMetricId: id }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + void auditLog("custom-metric.entry.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues, customMetricId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createCustomMetricEntrySchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.entry.create.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, customMetricId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.36.x — through `auditLog()`, the only writer that stamps the actor. - void auditLog("custom-metric.entry.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues, customMetricId: id }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { value, measuredAt, note } = parsed.data; diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 6ba09b4c0..3a2ec7842 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { @@ -81,36 +80,32 @@ export const PATCH = apiHandler( return apiError("Custom metric not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomMetricSchema, { maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateCustomMetricSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.metric.update.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, customMetricId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "custom-metric.metric.update.validation-failed", - details: JSON.stringify({ - issues: auditIssues, - customMetricId: id, - }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ + action: "custom-metric.metric.update.validation-failed", + countIssues: true, + meta: { customMetricId: id }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.metric.update.validation-failed", + details: JSON.stringify({ + issues: auditIssues, + customMetricId: id, + }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 3dd654c55..74bf526ba 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { @@ -63,33 +62,28 @@ export const POST = apiHandler( async function postCustomMetric(request: NextRequest) { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomMetricSchema, { maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createCustomMetricSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.metric.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: "custom-metric.metric.create.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ + action: "custom-metric.metric.create.validation-failed", + countIssues: true, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.metric.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const { name, diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 3f776a0bc..159696030 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -24,8 +24,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { @@ -99,27 +99,23 @@ async function postDayLog(request: NextRequest): Promise { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, cycleDayLogCreateSchema, { maxBytes: 64 * 1024, + errorCode: "cycle.day-log.invalid", + onInvalid: (error, rawBody) => { + const shape = unstableExternalIdShape(rawBody); + annotate({ + action: { name: "cycle.day-log.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape + ? unstableExternalIdMeta("cycle.day_log.create", [shape]) + : {}), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = cycleDayLogCreateSchema.safeParse(rawBody); - if (!parsed.success) { - const shape = unstableExternalIdShape(rawBody); - annotate({ - action: { name: "cycle.day-log.validation-failed" }, - meta: { - issue_count: parsed.error.issues.length, - ...(shape - ? unstableExternalIdMeta("cycle.day_log.create", [shape]) - : {}), - }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.day-log.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const tz = user.timezone ?? DEFAULT_TIMEZONE; diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 24c8edbc6..c7872438b 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -18,12 +18,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { withIdempotency } from "@/lib/idempotency"; import { cyclePeriodSchema } from "@/lib/validations/cycle"; @@ -63,25 +59,21 @@ async function postPeriod(request: NextRequest): Promise { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, cyclePeriodSchema, { maxBytes: 64 * 1024, + errorCode: "cycle.period.invalid", + onInvalid: (error, rawBody) => { + const shape = unstableExternalIdShape(rawBody); + annotate({ + action: { name: "cycle.period.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = cyclePeriodSchema.safeParse(rawBody); - if (!parsed.success) { - const shape = unstableExternalIdShape(rawBody); - annotate({ - action: { name: "cycle.period.validation-failed" }, - meta: { - issue_count: parsed.error.issues.length, - ...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}), - }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.period.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { action, date, externalId, loggedAt } = parsed.data; const tz = user.timezone ?? DEFAULT_TIMEZONE; diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts index dab4ac6c6..2bb8be1b5 100644 --- a/src/app/api/illness/episodes/[id]/resolve/route.ts +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -14,13 +14,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireIllnessEnabled } from "@/lib/illness/gate"; import { illnessEpisodeResolveSchema } from "@/lib/validations/illness"; import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; @@ -60,17 +55,12 @@ export const PATCH = apiHandler( }); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, illnessEpisodeResolveSchema, { maxBytes: 4 * 1024, + input: (rawBody) => rawBody ?? {}, + errorCode: "illness.episode.invalid", }); - if (jsonError) return jsonError; - - const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "illness.episode.invalid", - }); - } + if (!parsed.ok) return parsed.error; const resolvedAt = parsed.data.resolvedAt ? new Date(parsed.data.resolvedAt) diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index e91694740..f39f89c08 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -6,10 +6,9 @@ import { apiSuccess, apiError, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { hashToken } from "@/lib/auth/hmac"; import { checkAuthSurfaceRateLimit, @@ -152,45 +151,43 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { lastUsedAt: new Date() }, }); - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — external native ingest is a bulk-source; surface + // every Zod issue so a Telegram-bridge or HomeAssistant + // integration sees the full validation shape at once. Audit + // breadcrumb keyed `ingest.medication.validation-failed`. + const parsed = await parseJsonBody(request, externalIntakeSchema, { maxBytes: 64 * 1024, - }); - - if (jsonError) return jsonError; - const parsed = externalIntakeSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — external native ingest is a bulk-source; surface - // every Zod issue so a Telegram-bridge or HomeAssistant - // integration sees the full validation shape at once. Audit - // breadcrumb keyed `ingest.medication.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - const shape = unstableExternalIdShape(body, "idempotencyKey"); - annotate({ - action: { name: "ingest.medication.validation-failed" }, - meta: { - issue_count: issues.length, - ...(shape ? unstableExternalIdMeta("medication.ingest", [shape]) : {}), - }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; external - // ingest carries free-text `medicationName` + opaque - // `idempotencyKey`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: apiToken.userId, - action: "ingest.medication.validation-failed", - details: JSON.stringify({ issues: auditIssues }), + onInvalid: (error, body) => { + const shape = unstableExternalIdShape(body, "idempotencyKey"); + annotate({ + action: { name: "ingest.medication.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape + ? unstableExternalIdMeta("medication.ingest", [shape]) + : {}), }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ }); - return returnAllZodIssues(parsed.error, 422); - } + // v1.4.49 — strip `message` from the audit-ledger row; external + // ingest carries free-text `medicationName` + opaque + // `idempotencyKey`. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: apiToken.userId, + action: "ingest.medication.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const { medicationName, takenAt, idempotencyKey } = parsed.data; diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index d7f7e0c3c..cd6fd1043 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; import { prisma } from "@/lib/db"; @@ -111,34 +110,29 @@ export const PUT = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateLabResultSchema, { maxBytes: 16 * 1024, + action: "labs.update.validation-failed", + countIssues: true, + meta: { labResultId: id }, + errorCode: "labs.update.invalid", + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("labs.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, labResultId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - 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, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("labs.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, labResultId: id }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.update.invalid", - }); - } + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 4b49874ea..9db6f0e75 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -19,13 +19,8 @@ import { NextRequest } from "next/server"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -159,32 +154,27 @@ async function commitOcrRows(request: NextRequest) { // OCR_MAX_ROWS rows at every field's longest bound run past 256 KB; 512 // keeps a full report in one body with room to spare. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, ocrCommitSchema, { maxBytes: 512 * 1024, + action: "labs.ocr.commit.validation-failed", + countIssues: true, + onInvalid: (error) => { + // Free-text analyte / unit could land in a Zod issue message — strip values. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.ocr.commit.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => {}); + }, }); - if (jsonError) return jsonError; - - const parsed = ocrCommitSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "labs.ocr.commit.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - // Free-text analyte / unit could land in a Zod issue message — strip values. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "labs.ocr.commit.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => {}); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const inserted: ReturnType[] = []; const skipped: OcrSkippedRowDto[] = []; diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 31bd837a1..4732acab9 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -7,9 +7,9 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -157,35 +157,29 @@ async function postLabResult(request: NextRequest) { // that leaves the owner with a usable catalogue afterwards. const { user } = await requireRecordAuth("write", "labs"); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createLabResultSchema, { maxBytes: 16 * 1024, + action: "labs.create.validation-failed", + countIssues: true, + errorCode: "labs.create.invalid", + onInvalid: (error) => { + // Free-text `analyte` / `unit` / `note` could land verbatim in a Zod + // issue message — strip values from the audit-ledger breadcrumb. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // 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 */ + }); + }, }); - 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, - }); - // 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", - }); - } + if (!parsed.ok) return parsed.error; const { biomarkerId, diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 7e1b8dc9e..336e45d68 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -21,10 +21,9 @@ import { apiSuccess, apiError, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { @@ -92,33 +91,32 @@ export const PATCH = apiHandler( return apiError("Measurement reminder not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateMeasurementReminderSchema.safeParse(body); - if (!parsed.success) { - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "measurement-reminders.update.validation-failed" }, - meta: { issue_count: issues.length, reminderId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("measurement-reminders.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, reminderId: id }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + const parsed = await parseJsonBody( + request, + updateMeasurementReminderSchema, + { + maxBytes: 16 * 1024, + action: "measurement-reminders.update.validation-failed", + countIssues: true, + meta: { reminderId: id }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurement-reminders.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, reminderId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, + }, + ); + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/measurement-reminders/[id]/snooze/route.ts b/src/app/api/measurement-reminders/[id]/snooze/route.ts index c61146547..f50fc4f26 100644 --- a/src/app/api/measurement-reminders/[id]/snooze/route.ts +++ b/src/app/api/measurement-reminders/[id]/snooze/route.ts @@ -18,13 +18,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { - apiSuccess, - apiError, - getClientIp, - safeJson, - returnAllZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; @@ -43,12 +38,11 @@ export const POST = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; - const body = await safeJson(request); - if (body.error) return body.error; - const parsed = snoozeMeasurementReminderSchema.safeParse(body.data); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + const parsed = await parseJsonBody( + request, + snoozeMeasurementReminderSchema, + ); + if (!parsed.ok) return parsed.error; // An appointment reminder is not addressable here — see the sibling // routes; snoozing one would move a date the visit record still owns. diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 150bbe793..e1bad22ac 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -9,13 +9,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; @@ -57,33 +52,27 @@ async function postReminder(request: NextRequest): Promise { // row is listed, editable and removable by the owner. const { user } = await requireRecordAuth("manage", "measurements"); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createMeasurementReminderSchema, { maxBytes: 16 * 1024, + action: "measurement-reminders.create.validation-failed", + countIssues: true, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurement-reminders.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createMeasurementReminderSchema.safeParse(body); - if (!parsed.success) { - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "measurement-reminders.create.validation-failed" }, - meta: { issue_count: issues.length }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("measurement-reminders.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 821017691..e0fd951f8 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -8,9 +8,9 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { updateMeasurementSchema, USER_CORRECTABLE_MEASUREMENT_SOURCES, @@ -75,40 +75,35 @@ export const PUT = apiHandler( return apiError("Measurement not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — measurement edit hot path; multi-issue 422 + + // audit breadcrumb keyed `measurements.update.validation-failed`. + const parsed = await parseJsonBody(request, updateMeasurementSchema, { maxBytes: 64 * 1024, + action: "measurements.update.validation-failed", + countIssues: true, + meta: { measurement_id: id }, + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row; the + // update schema carries free-text `notes`. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurements.update.validation-failed", { + userId: user.id, + details: { + issues: auditIssues, + measurementId: id, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - const parsed = updateMeasurementSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — measurement edit hot path; multi-issue 422 + - // audit breadcrumb keyed `measurements.update.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "measurements.update.validation-failed" }, - meta: { issue_count: issues.length, measurement_id: id }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; the - // update schema carries free-text `notes`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("measurements.update.validation-failed", { - userId: user.id, - details: { - issues: auditIssues, - measurementId: id, - }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index b57b4b41f..934f49d45 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -6,10 +6,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { type MedicationImportPayload, @@ -121,55 +120,48 @@ export const POST = apiHandler( return apiError("Too many import submissions, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — CSV import; preserve the `Invalid format:` prefix + // semantics for the existing client-side branch by setting the + // `errorCode: "medication.intake.import.invalid_format"` meta. + // The client UI can now branch on `meta.errorCode` while every + // Zod issue is also surfaced under `details.issues`. Bulk-import + // path → audit breadcrumb keyed + // `medications.intake.import.validation-failed`. + const parsed = await parseJsonBody(request, validatedImportSchema, { maxBytes: 1024 * 1024, + // Support both direct arrays and object-with-array payloads + input: (body) => + Array.isArray(body) + ? body + : typeof body === "object" && body !== null + ? Object.values(body).find((value) => Array.isArray(value)) + : null, + action: "medications.intake.import.validation-failed", + countIssues: true, + meta: { medication_id: id }, + errorCode: "medication.intake.import.invalid_format", + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row; CSV + // imports carry caller-provided strings that Zod may echo. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("medications.intake.import.validation-failed", { + userId: user.id, + details: { + issues: auditIssues, + medicationId: id, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - - // Support both direct arrays and object-with-array payloads - const payload = Array.isArray(body) - ? body - : typeof body === "object" && body !== null - ? Object.values(body).find((value) => Array.isArray(value)) - : null; - - const parsed = validatedImportSchema.safeParse(payload); - if (!parsed.success) { - // v1.4.43 — CSV import; preserve the `Invalid format:` prefix - // semantics for the existing client-side branch by setting the - // `errorCode: "medication.intake.import.invalid_format"` meta. - // The client UI can now branch on `meta.errorCode` while every - // Zod issue is also surfaced under `details.issues`. Bulk-import - // path → audit breadcrumb keyed - // `medications.intake.import.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "medications.intake.import.validation-failed" }, - meta: { issue_count: issues.length, medication_id: id }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; CSV - // imports carry caller-provided strings that Zod may echo. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("medications.intake.import.validation-failed", { - userId: user.id, - details: { - issues: auditIssues, - medicationId: id, - }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medication.intake.import.invalid_format", - }); - } + if (!parsed.ok) return parsed.error; const entries = parsed.data; const normalized: MedicationImportPayload = { diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index afe537197..d35be08d9 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -32,13 +32,8 @@ import { destroyedDetails, overwriteDetails, } from "@/lib/sharing/audit-details"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; import { @@ -68,15 +63,11 @@ export const PATCH = apiHandler( return apiError("Medication not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, scheduleRevisionUpdateSchema, { maxBytes: 16 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const validFrom = new Date(parsed.data.validFrom); const validUntil = new Date(parsed.data.validUntil); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 8f27bb9c6..18be90e7a 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -27,13 +27,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; import { @@ -151,15 +146,11 @@ export const POST = apiHandler( return apiError("Medication not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, scheduleRevisionCreateSchema, { maxBytes: 16 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = scheduleRevisionCreateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const validFrom = new Date(parsed.data.validFrom); const validUntil = new Date(parsed.data.validUntil); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 3868a8ac3..4764d4d08 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -55,12 +55,8 @@ import type { 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 { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { assertAiEgress } from "@/lib/ai/capabilities/egress"; @@ -164,18 +160,16 @@ async function handleExtract(request: NextRequest): Promise { const userId = auth.user.id; // Body parse + Zod validation. - const { data: body, error: jsonError } = await safeJson(request, { + const validated = await parseJsonBody(request, requestSchema, { maxBytes: 16 * 1024, + onInvalid: (error) => { + annotate({ + action: { name: "medications.extract.invalid" }, + meta: { issues: error.issues.length }, + }); + }, }); - if (jsonError) return jsonError; - const validated = requestSchema.safeParse(body); - if (!validated.success) { - annotate({ - action: { name: "medications.extract.invalid" }, - meta: { issues: validated.error.issues.length }, - }); - return returnAllZodIssues(validated.error); - } + if (!validated.ok) return validated.error; const { text, locale, today: todayOverride } = validated.data; diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 02f57d119..e829f1f97 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -24,9 +24,9 @@ import { apiError, apiSuccess, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { auditLog } from "@/lib/auth/audit"; @@ -239,28 +239,23 @@ async function postAssessment(request: NextRequest): Promise { } // Bounded body read BEFORE validation (the item answers are small; cap so an - // unbounded body can never reach `request.json()`). Destructure the - // `{ data, error }` envelope — passing the wrapper to `safeParse` would never - // match the schema, which is what left this route dead-on-arrival. - const { data: body, error: jsonError } = await safeJson(request, { + // unbounded body can never reach `request.json()`). + const parsed = await parseJsonBody(request, createAssessmentSchema, { maxBytes: 8 * 1024, + onInvalid: (error, body) => { + const shape = unstableExternalIdShape(body); + annotate({ + action: { name: "mental-health.create.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape + ? unstableExternalIdMeta("mental_health.assessment.create", [shape]) + : {}), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createAssessmentSchema.safeParse(body); - if (!parsed.success) { - const shape = unstableExternalIdShape(body); - annotate({ - action: { name: "mental-health.create.validation-failed" }, - meta: { - issue_count: sanitiseZodIssues(parsed.error.issues).length, - ...(shape - ? unstableExternalIdMeta("mental_health.assessment.create", [shape]) - : {}), - }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { instrument, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 7730d4d01..ad656d953 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -5,10 +5,9 @@ import { apiSuccess, apiError, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; @@ -88,39 +87,33 @@ export const PUT = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — mood edit hot path; multi-issue 422 + audit + // breadcrumb keyed `mood-entries.update.validation-failed`. + const parsed = await parseJsonBody(request, updateMoodEntrySchema, { maxBytes: 64 * 1024, + action: "mood-entries.update.validation-failed", + countIssues: true, + meta: { moodEntryId: id }, + errorCode: "mood.update.invalid", + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row; mood + // update carries free-text `note` + `tags`. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("mood-entries.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, moodEntryId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - const parsed = updateMoodEntrySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — mood edit hot path; multi-issue 422 + audit - // breadcrumb keyed `mood-entries.update.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "mood-entries.update.validation-failed" }, - meta: { issue_count: issues.length, moodEntryId: id }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; mood - // update carries free-text `note` + `tags`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("mood-entries.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, moodEntryId: id }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "mood.update.invalid", - }); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 67f38fe4d..118a44724 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -5,9 +5,9 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -108,44 +108,39 @@ async function postMoodEntry(request: NextRequest) { // the `externalId` refusal below. const { user, actor } = await requireRecordAuth("manage", "mind"); - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — iOS mood log hot path; multi-issue 422 + audit + // breadcrumb keyed `mood-entries.create.validation-failed`. + const parsed = await parseJsonBody(request, createMoodEntrySchema, { maxBytes: 64 * 1024, + errorCode: "mood.create.invalid", + onInvalid: (error, body) => { + const shape = unstableExternalIdShape(body); + annotate({ + action: { name: "mood-entries.create.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}), + }, + }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // mood-create schema's free-text `note` + `tags` could land in a + // Zod issue message verbatim. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("mood-entries.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - const parsed = createMoodEntrySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — iOS mood log hot path; multi-issue 422 + audit - // breadcrumb keyed `mood-entries.create.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - const shape = unstableExternalIdShape(body); - annotate({ - action: { name: "mood-entries.create.validation-failed" }, - meta: { - issue_count: issues.length, - ...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}), - }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; the - // mood-create schema's free-text `note` + `tags` could land in a - // Zod issue message verbatim. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("mood-entries.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "mood.create.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { mood,