mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/v146-server-parse' into feat/v146-server
This commit is contained in:
commit
7ea247e193
47 files changed
+760
-1113
No files matched your search
@@ -1,21 +1,15 @@
|
||||
{
|
||||
"$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": 61,
|
||||
"total": 61,
|
||||
"entries": {
|
||||
"app/api/admin/ai-settings/route.ts": 1,
|
||||
"app/api/admin/drain-per-sample-cumulative/route.ts": 1,
|
||||
"app/api/admin/encryption/key-backup/confirm/route.ts": 1,
|
||||
"app/api/admin/encryption/key-backup/verify/route.ts": 1,
|
||||
"app/api/admin/invites/route.ts": 1,
|
||||
"app/api/admin/notifications/reminder-check/route.ts": 1,
|
||||
"app/api/admin/rollups/recompute/route.ts": 1,
|
||||
"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,85 +20,46 @@
|
||||
"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,
|
||||
"app/api/dawarich/disconnect/route.ts": 1,
|
||||
"app/api/dawarich/settings/route.ts": 1,
|
||||
"app/api/dawarich/sync/route.ts": 1,
|
||||
"app/api/devices/route.ts": 1,
|
||||
"app/api/documents/inbound/[id]/extract/route.ts": 1,
|
||||
"app/api/documents/inbound/[id]/index/route.ts": 1,
|
||||
"app/api/documents/inbound/[id]/suggest/route.ts": 1,
|
||||
"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,
|
||||
"app/api/insights/generate/route.ts": 1,
|
||||
"app/api/insights/layout/route.ts": 1,
|
||||
"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,
|
||||
"app/api/onboarding/answers/route.ts": 1,
|
||||
"app/api/onboarding/complete/route.ts": 1,
|
||||
"app/api/onboarding/disclaimer/route.ts": 1,
|
||||
"app/api/onboarding/restart/route.ts": 1,
|
||||
"app/api/onboarding/tour/route.ts": 1,
|
||||
"app/api/oura/credentials/route.ts": 1,
|
||||
"app/api/polar/credentials/route.ts": 1,
|
||||
"app/api/record-settings/[family]/route.ts": 1,
|
||||
"app/api/search/route.ts": 1,
|
||||
"app/api/settings/email/route.ts": 1,
|
||||
"app/api/settings/ntfy/route.ts": 1,
|
||||
"app/api/settings/telegram/route.ts": 1,
|
||||
"app/api/settings/webhook/route.ts": 1,
|
||||
"app/api/strava/credentials/route.ts": 1,
|
||||
"app/api/user/ai-provider/route.ts": 1,
|
||||
"app/api/user/profile/route.ts": 1,
|
||||
"app/api/whoop/credentials/route.ts": 1,
|
||||
"app/api/withings/credentials/route.ts": 1,
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
* every `if (!<result>.success)` block whose identifier came from a `safeParse`
|
||||
* in that file is read on its own, and only the blocks that answer 422 are
|
||||
* judged — a 400, a 401 or a 403 raised off a parse result is a different
|
||||
* refusal and is left alone.
|
||||
* refusal and is left alone. A route that reads its body through
|
||||
* `parseJsonBody` has no such block: each call is a site of its own, carrying
|
||||
* the issue list by construction, and its `message` option is read as the
|
||||
* refused string by the token sweep below.
|
||||
*
|
||||
* The second half of the same audit item is the dotted token in `error`, and
|
||||
* it is checked by its own sweep at the bottom of this file rather than by the
|
||||
@@ -94,11 +97,58 @@ interface RefusalSite {
|
||||
carriesIssues: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `parseJsonBody(…)` call in a file, as its paren-balanced source text
|
||||
* with the line it starts on. The helper reads, validates and refuses in one
|
||||
* call (`src/lib/api/body.ts`), so a route that uses it has no
|
||||
* `if (!x.success)` block for the matcher above to find; its refusal is the
|
||||
* helper's envelope, which always carries `details.issues`.
|
||||
*/
|
||||
function parseJsonBodyCalls(src: string): { text: string; line: number }[] {
|
||||
const calls: { text: string; line: number }[] = [];
|
||||
for (const m of src.matchAll(/\bparseJsonBody\s*\(/g)) {
|
||||
const start = m.index ?? 0;
|
||||
let depth = 0;
|
||||
let end = src.length;
|
||||
for (let j = start + m[0].length - 1; j < src.length; j++) {
|
||||
if (src[j] === "(") depth++;
|
||||
else if (src[j] === ")") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
calls.push({
|
||||
text: src.slice(start, end),
|
||||
line: src.slice(0, start).split("\n").length,
|
||||
});
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** The status a `parseJsonBody` call refuses with: its `status` option, else 422. */
|
||||
function parseJsonBodyStatus(call: string): number {
|
||||
const m = /\bstatus:\s*(\d{3})\b/.exec(call);
|
||||
return m ? Number(m[1]) : 422;
|
||||
}
|
||||
|
||||
/** Every 422 refusal decided by a `safeParse` result, across the route tree. */
|
||||
function scanRefusalSites(): RefusalSite[] {
|
||||
const sites: RefusalSite[] = [];
|
||||
for (const file of routeFiles(API_ROOT)) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
// A `parseJsonBody` refusal is a 422 with the issue list unless the call
|
||||
// names another status, which is then a different refusal, as below.
|
||||
for (const call of parseJsonBodyCalls(src)) {
|
||||
if (parseJsonBodyStatus(call.text) !== 422) continue;
|
||||
sites.push({
|
||||
route: relative(process.cwd(), file).split(sep).join("/"),
|
||||
line: call.line,
|
||||
carriesIssues: true,
|
||||
});
|
||||
}
|
||||
// Identifiers bound to a parse result. Both call styles are in the tree:
|
||||
// `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`.
|
||||
const parsed = new Set<string>();
|
||||
@@ -228,12 +278,20 @@ function tokenRefusals(): string[] {
|
||||
/apiValidationError\(\s*"([^"]+)"/g,
|
||||
/new HttpError\(\s*422\s*,\s*"([^"]+)"/g,
|
||||
];
|
||||
const refused: string[] = [];
|
||||
for (const shape of shapes) {
|
||||
for (const m of src.matchAll(shape)) {
|
||||
if (!DOTTED_TOKEN.test(m[1])) continue;
|
||||
const key = `${route} ${m[1]}`;
|
||||
if (!found.includes(key)) found.push(key);
|
||||
}
|
||||
for (const m of src.matchAll(shape)) refused.push(m[1]);
|
||||
}
|
||||
// `parseJsonBody`'s `message` option is the 422's `error` string.
|
||||
for (const call of parseJsonBodyCalls(src)) {
|
||||
if (parseJsonBodyStatus(call.text) !== 422) continue;
|
||||
const m = /\bmessage:\s*"([^"]+)"/.exec(call.text);
|
||||
if (m) refused.push(m[1]);
|
||||
}
|
||||
for (const text of refused) {
|
||||
if (!DOTTED_TOKEN.test(text)) continue;
|
||||
const key = `${route} ${text}`;
|
||||
if (!found.includes(key)) found.push(key);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
|
||||
@@ -11,13 +11,8 @@ import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } 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 { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -38,10 +33,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = confirmSchema.safeParse(body.data);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error);
|
||||
const parsed = await parseJsonBody(request, confirmSchema, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const result = await confirmKeyBackup({
|
||||
keyId: parsed.data.keyId,
|
||||
|
||||
@@ -13,12 +13,8 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } 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 { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
@@ -62,15 +58,11 @@ export const GET = apiHandler(async () => {
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
const parsed = await parseJsonBody(req, inviteCreateSchema, {
|
||||
maxBytes: 16 * 1024,
|
||||
input: (body) => body ?? {},
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = inviteCreateSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { expiresInDays, maxUses } = parsed.data;
|
||||
const rawToken = generateInviteToken();
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { z } from "zod/v4";
|
||||
@@ -53,19 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.webPushVapid.generate" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const parsed = await parseJsonBody(request, generateSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
input: (body) => body ?? {},
|
||||
message: "Invalid request",
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = generateSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const existing = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
@@ -48,15 +49,11 @@ export const DELETE = apiHandler(async (req: Request) => {
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
const parsed = await parseJsonBody(req, dismissSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
input: (body) => body ?? {},
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = dismissSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const current = await getPendingQuestionsForUser(user.id);
|
||||
const remaining =
|
||||
|
||||
@@ -18,12 +18,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
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 { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -50,16 +46,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
// v1.4.43 — multi-issue 400 (consent routes use 400 not 422).
|
||||
const parsed = await parseJsonBody(request, consentPostBody, {
|
||||
maxBytes: 128 * 1024,
|
||||
status: 400,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = consentPostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — multi-issue 400 (consent routes use 400 not 422).
|
||||
return returnAllZodIssues(parsed.error, 400);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { kind, artefact, signedAt, confirm } = parsed.data;
|
||||
if (confirm !== true) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,12 +16,8 @@
|
||||
* same request.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess, sanitiseZodIssues } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
@@ -60,42 +56,37 @@ const prefsSchema = z
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
// v1.4.43 — sibling of `/api/dashboard/widgets`; the per-chart
|
||||
// overlay popover hits this on every toggle so the multi-issue
|
||||
// envelope matches widgets exactly. Audit breadcrumb keyed
|
||||
// `dashboard.chart-overlay.validation-failed`, deduped via the
|
||||
// shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a
|
||||
// misbehaving iOS client looping the popover cannot flood the
|
||||
// audit ledger.
|
||||
const parsed = await parseJsonBody(request, prefsSchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
action: "dashboard.chart-overlay.validation-failed",
|
||||
countIssues: true,
|
||||
onInvalid: (error) => {
|
||||
if (
|
||||
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
|
||||
) {
|
||||
const issues = sanitiseZodIssues(error.issues);
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.chart-overlay.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = prefsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — sibling of `/api/dashboard/widgets`; the per-chart
|
||||
// overlay popover hits this on every toggle so the multi-issue
|
||||
// envelope matches widgets exactly. Audit breadcrumb keyed
|
||||
// `dashboard.chart-overlay.validation-failed`, deduped via the
|
||||
// shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a
|
||||
// misbehaving iOS client looping the popover cannot flood the
|
||||
// audit ledger.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "dashboard.chart-overlay.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
if (
|
||||
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
|
||||
) {
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.chart-overlay.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
// Read-modify-write under a row lock, so two concurrent toggles (two
|
||||
// tabs, two charts flipped in quick succession) can neither drop one
|
||||
|
||||
@@ -24,13 +24,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, sanitiseZodIssues } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -113,42 +108,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Too many device registrations, please slow down", 429);
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
// v1.4.43 — iOS device-registration multi-issue 422 + audit
|
||||
// breadcrumb. APNs-token + bundleId + environment + model + locale
|
||||
// can all fail simultaneously on a misconfigured client build.
|
||||
const parsed = await parseJsonBody(request, deviceSchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = deviceSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — iOS device-registration multi-issue 422 + audit
|
||||
// breadcrumb. APNs-token + bundleId + environment + model + locale
|
||||
// can all fail simultaneously on a misconfigured client build.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "devices.register.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row. Device
|
||||
// registration carries `token` + `apnsToken`; the default Zod
|
||||
// message for `invalid_format` echoes the offending value, which
|
||||
// would persist an APNs token (or a forged near-miss) into the
|
||||
// audit ledger.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "devices.register.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
action: "devices.register.validation-failed",
|
||||
countIssues: true,
|
||||
onInvalid: (error) => {
|
||||
// v1.4.49 — strip `message` from the audit-ledger row. Device
|
||||
// registration carries `token` + `apnsToken`; the default Zod
|
||||
// message for `invalid_format` echoes the offending value, which
|
||||
// would persist an APNs token (or a forged near-miss) into the
|
||||
// audit ledger.
|
||||
const auditIssues = sanitiseZodIssues(error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "devices.register.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const {
|
||||
token,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -31,14 +31,8 @@ import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} 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 { Prisma } from "@/generated/prisma/client";
|
||||
@@ -79,28 +73,19 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
|
||||
return apiError("Too many feedback submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error } = await safeJson<unknown>(request, {
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
const parsed = await parseJsonBody(request, coachMessageFeedbackSchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
action: "insights.coach.message.feedback",
|
||||
meta: { outcome: "validation_failed" },
|
||||
message: "feedback.body.invalid",
|
||||
errorCode: "feedback.body.invalid",
|
||||
});
|
||||
if (error) return error;
|
||||
const parsed = coachMessageFeedbackSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.coach.message.feedback" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
return apiValidationError(
|
||||
"feedback.body.invalid",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "feedback.body.invalid" },
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
const body = parsed.data;
|
||||
|
||||
// Verify ownership only — we no longer touch encryptedContent. The
|
||||
|
||||
@@ -35,12 +35,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -223,27 +219,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Too many ECG submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
|
||||
const parsed = await parseJsonBody(request, ecgIngestSchema, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
onInvalid: (error) => {
|
||||
// Which field was refused and why, never the value: without it a refused
|
||||
// recording is a bare "invalid" in the log and the cause has to be guessed.
|
||||
annotate({
|
||||
action: { name: "insights.ecg.ingest" },
|
||||
meta: {
|
||||
outcome: "invalid",
|
||||
invalidFields: error.issues
|
||||
.map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`)
|
||||
.slice(0, 8)
|
||||
.join(","),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = ecgIngestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
// Which field was refused and why, never the value: without it a refused
|
||||
// recording is a bare "invalid" in the log and the cause has to be guessed.
|
||||
annotate({
|
||||
action: { name: "insights.ecg.ingest" },
|
||||
meta: {
|
||||
outcome: "invalid",
|
||||
invalidFields: parsed.error.issues
|
||||
.map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`)
|
||||
.slice(0, 8)
|
||||
.join(","),
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const body = parsed.data;
|
||||
const result = await persistEcgRecording(
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} 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 { Prisma } from "@/generated/prisma/client";
|
||||
@@ -57,23 +51,17 @@ async function handlePost(request: NextRequest) {
|
||||
return apiError("Too many feedback submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error } = await safeJson<unknown>(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
const parsed = await parseJsonBody(
|
||||
request,
|
||||
recommendationFeedbackRequestSchema,
|
||||
{
|
||||
maxBytes: 64 * 1024,
|
||||
action: "insights.recommendation.feedback",
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid feedback payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
message: "Invalid feedback payload",
|
||||
},
|
||||
);
|
||||
if (!parsed.ok) return parsed.error;
|
||||
const body = parsed.data;
|
||||
|
||||
const attribution = await resolveFeedbackAttribution(user.id);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
@@ -90,19 +86,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "insights.provider_chain.update" } });
|
||||
|
||||
const { data: body, error } = await safeJson<unknown>(request, {
|
||||
const parsed = await parseJsonBody(request, providerChainPutSchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
message: "Invalid provider chain payload",
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = providerChainPutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid provider chain payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
// Defence-in-depth: reject duplicate provider types so a stale-tab
|
||||
// resubmit cannot create a chain that walks the same entry twice.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,13 +24,8 @@
|
||||
import type { 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, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -68,21 +63,13 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
|
||||
const parsed = await parseJsonBody(request, onboardingAnswerSchema, {
|
||||
maxBytes: 16 * 1024,
|
||||
action: "onboarding.needs.save",
|
||||
meta: { outcome: "validation_failed" },
|
||||
errorCode: "onboarding.answers.invalid",
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = onboardingAnswerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.save" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "onboarding.answers.invalid",
|
||||
});
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
const answer = parsed.data;
|
||||
|
||||
const existing = await prisma.onboardingRecord.findUnique({
|
||||
|
||||
@@ -2,14 +2,8 @@ import { prisma, toJson } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} 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 { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { normalisePrefs } from "@/lib/modules/gate";
|
||||
@@ -34,7 +28,6 @@ import {
|
||||
import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
|
||||
|
||||
/**
|
||||
@@ -75,22 +68,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "onboarding.complete" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const result = await parseJsonBody(request, onboardingCompleteSchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
message: "Invalid input",
|
||||
errorCode: "onboarding.complete.invalid",
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const result = z.safeParse(onboardingCompleteSchema, body);
|
||||
if (!result.success) {
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.complete.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!result.ok) return result.error;
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
onboardingCompletedAt: new Date(),
|
||||
|
||||
@@ -3,12 +3,8 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
|
||||
|
||||
/**
|
||||
@@ -36,22 +32,12 @@ const bodySchema = z
|
||||
export const POST = apiHandler(async (request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
const parsed = await parseJsonBody(request, bodySchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
message: "Invalid input",
|
||||
errorCode: "onboarding.disclaimer.invalid",
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = z.safeParse(bodySchema, rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.disclaimer.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
// The server pins the canonical version it stamps — the body version is
|
||||
// only a freshness signal, never the source of truth.
|
||||
|
||||
@@ -31,13 +31,8 @@
|
||||
import type { 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, toJson } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
@@ -74,21 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
|
||||
const parsed = await parseJsonBody(request, onboardingRestartSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
input: (body) => body ?? {},
|
||||
action: "onboarding.needs.restart",
|
||||
meta: { outcome: "validation_failed" },
|
||||
errorCode: "onboarding.restart.invalid",
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = onboardingRestartSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.restart" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "onboarding.restart.invalid",
|
||||
});
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const steps = defaultOnboardingSteps();
|
||||
const written = await prisma.onboardingRecord.upsert({
|
||||
|
||||
@@ -4,12 +4,8 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -54,22 +50,12 @@ const tourBodySchema = z
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const parsed = await parseJsonBody(request, tourBodySchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
message: "Invalid input",
|
||||
errorCode: "onboarding.tour.invalid",
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = z.safeParse(tourBodySchema, body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.tour.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
// Build the update field-by-field (no mass assignment). `completed`
|
||||
// drives the coarse boolean; a replay (`completed:false`) wipes the
|
||||
|
||||
+15
-23
@@ -28,12 +28,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } 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 { prisma } from "@/lib/db";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -71,24 +67,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Too many searches. Please retry shortly.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
q?: unknown;
|
||||
limit?: unknown;
|
||||
}>(request, { maxBytes: SEARCH_BODY_MAX_BYTES });
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = searchQuerySchema.safeParse({
|
||||
q: body?.q ?? undefined,
|
||||
limit: body?.limit ?? undefined,
|
||||
const parsed = await parseJsonBody(request, searchQuerySchema, {
|
||||
maxBytes: SEARCH_BODY_MAX_BYTES,
|
||||
input: (raw) => {
|
||||
const body = raw as { q?: unknown; limit?: unknown };
|
||||
return {
|
||||
q: body?.q ?? undefined,
|
||||
limit: body?.limit ?? undefined,
|
||||
};
|
||||
},
|
||||
action: "search.query.invalid",
|
||||
countIssues: true,
|
||||
errorCode: "search.invalid",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "search.query.invalid" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "search.invalid",
|
||||
});
|
||||
}
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const slot = beginSearch(actor.id, user.id);
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
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 { auditLog } from "@/lib/auth/audit";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
@@ -170,30 +165,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.ai-provider.update" } });
|
||||
|
||||
const { data: rawBody, error } = await safeJson<unknown>(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
// Every accepted field states its type in the schema, so a wrongly-typed
|
||||
// value is a named issue rather than a key quietly dropped on the floor.
|
||||
// The multi-issue envelope is safe to return even though this body carries
|
||||
// plaintext credentials: Zod's type and range messages name the received
|
||||
// TYPE, never the received value. See the note in the validations module
|
||||
// before adding a `.refine()` here.
|
||||
const parsed = aiProviderPatchSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "user.ai-provider.update" },
|
||||
meta: {
|
||||
outcome: "validation_failed",
|
||||
issue_count: parsed.error.issues.length,
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "ai_provider.invalid",
|
||||
});
|
||||
}
|
||||
const parsed = await parseJsonBody(request, aiProviderPatchSchema, {
|
||||
maxBytes: 64 * 1024,
|
||||
errorCode: "ai_provider.invalid",
|
||||
onInvalid: (error) => {
|
||||
annotate({
|
||||
action: { name: "user.ai-provider.update" },
|
||||
meta: {
|
||||
outcome: "validation_failed",
|
||||
issue_count: error.issues.length,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
const body = parsed.data;
|
||||
|
||||
// v1.39.3 — whose configuration this is. Settings saved on an admin account
|
||||
|
||||
Reference in new issue
Block a user