fix(profile): save every valid field even when a sibling is invalid

A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.

The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:25:18 +02:00
1 parent bed634fd67
commit e49bd866dd
5 files changed
+289 -16

No files matched your search

+10 -1
View File
@@ -1,6 +1,7 @@
import {
apiSuccess,
apiError,
apiValidationError,
getClientIp,
safeJson,
} from "@/lib/api-response";
@@ -19,7 +20,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
return apiError(result.message, result.status);
return result.issues
? apiValidationError(
result.message,
result.issues,
result.status,
result.errorCode ? { errorCode: result.errorCode } : undefined,
)
: apiError(result.message, result.status);
}
annotate({ action: { name: "auth.profile.update" } });
@@ -37,5 +45,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
insurerName: result.user.insurerName,
insurerIkNumber: result.user.insurerIkNumber,
hasInsuranceNumber: result.user.hasInsuranceNumber,
...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}),
});
});
+10 -1
View File
@@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
} from "@/lib/api-response";
@@ -101,7 +102,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
return apiError(result.message, result.status);
return result.issues
? apiValidationError(
result.message,
result.issues,
result.status,
result.errorCode ? { errorCode: result.errorCode } : undefined,
)
: apiError(result.message, result.status);
}
annotate({ action: { name: "user.profile.update" } });
@@ -122,5 +130,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
insurerName: result.user.insurerName,
insurerIkNumber: result.user.insurerIkNumber,
hasInsuranceNumber: result.user.hasInsuranceNumber,
...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}),
});
});