mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
f06df5fd71
commit
af14d0aa7e
28 files changed
+495
-698
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<Response> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Response> {
|
||||
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;
|
||||
|
||||
@@ -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<Response> {
|
||||
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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<typeof serialiseLabResult>[] = [];
|
||||
const skipped: OcrSkippedRowDto[] = [];
|
||||
|
||||
+22
-28
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Response> {
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Response> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<Response> {
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user