Files
HealthLog/src/app/api/user/profile/route.ts
T
Marc-André Bombeck e49bd866dd 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.
2026-07-30 23:25:18 +02:00

136 lines
4.5 KiB
TypeScript

/**
* iOS-friendly user profile endpoint.
*
* GET → flattened profile fields (camelCase) for the native client.
* Aliased over the same data exposed by `/api/auth/me`.
* PATCH → delegates to the shared `applyProfileUpdate` helper used by
* `/api/auth/profile` PUT — same validation, same audit log.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "user.profile.get" } });
// Overlap the profile-row read with the module-map resolve (a separate
// memoised Postgres round-trip) — both feed the same response and have
// no ordering dependency.
const [dbUser, modules] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
username: true,
displayName: true,
email: true,
dateOfBirth: true,
gender: true,
heightCm: true,
locale: true,
timezone: true,
timeFormat: true,
dateFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
insurerIkNumber: true,
insuranceNumberEncrypted: true,
},
}),
// v1.18.0 — resolved module enable/disable map, identical to the
// projection `GET /api/auth/me` returns. The iOS app reads this alias
// endpoint, so it must carry the same server-authoritative flags; a
// `false` key means the module surface disappears end-to-end.
resolveModuleMap(user.id),
]);
// v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s
// the profile fetch.
let insuranceNumber: string | null = null;
if (dbUser?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(dbUser.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
return apiSuccess({
username: dbUser?.username ?? user.username,
displayName: dbUser?.displayName ?? null,
email: dbUser?.email ?? null,
dateOfBirth: dbUser?.dateOfBirth?.toISOString() ?? null,
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
timeFormat: dbUser?.timeFormat ?? "AUTO",
dateFormat: dbUser?.dateFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
fullName: dbUser?.fullName ?? null,
insurerName: dbUser?.insurerName ?? null,
insurerIkNumber: dbUser?.insurerIkNumber ?? null,
insuranceNumber,
// v1.18.0 — module enable/disable map, matching the /api/auth/me
// projection exactly so the native client gets module flags from the
// endpoint it actually reads.
modules,
});
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
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" } });
return apiSuccess({
username: result.user.username,
displayName: result.user.displayName,
email: result.user.email,
dateOfBirth: result.user.dateOfBirth?.toISOString() ?? null,
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
timeFormat: result.user.timeFormat,
dateFormat: result.user.dateFormat,
moodReminderEnabled: result.user.moodReminderEnabled,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
insurerIkNumber: result.user.insurerIkNumber,
hasInsuranceNumber: result.user.hasInsuranceNumber,
...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}),
});
});