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,