Files
HealthLog/src/lib/auth/profile-update.ts
T
Marc-André Bombeck 86a9ca2e1e feat(profile): persist and expose the date-format preference
Accept dateFormat on the profile PATCH field-by-field, echo it from
the profile + /me reads, mirror it into the client auth user, and add
the field to the OpenAPI request/response schemas.
2026-06-26 12:15:09 +02:00

223 lines
7.7 KiB
TypeScript

/**
* Shared profile-update logic. The web client hits `/api/auth/profile` PUT
* and the iOS client hits `/api/user/profile` PATCH — both funnel through
* `applyProfileUpdate` so we don't fork the validation/audit path.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { isValidTimezone } from "@/lib/tz/format";
import { encrypt } from "@/lib/crypto";
import {
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { z } from "zod/v4";
const extendedProfileSchema = profileSchema.extend({
displayName: z.string().min(1).max(80).nullable().optional(),
locale: z.enum(["de", "en"]).nullable().optional(),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
moodReminderEnabled: z.boolean().optional(),
// Hour-cycle display preference. AUTO follows the locale convention,
// H12 forces AM/PM, H24 forces 24-hour.
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
// Date-order display preference. AUTO follows the locale convention,
// DMY pins day-month-year, MDY month-day-year, YMD ISO yyyy-MM-dd.
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(),
});
export interface ApplyProfileResult {
ok: true;
user: {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: string;
heightCm: number | null;
dateOfBirth: Date | null;
gender: string | null;
timezone: string;
locale: string | null;
timeFormat: "AUTO" | "H12" | "H24";
dateFormat: "AUTO" | "DMY" | "MDY" | "YMD";
moodReminderEnabled: boolean;
// v1.7.0 — patient-identity fields. `insuranceNumber` is returned in
// plaintext (decrypted on read by the route) so the client can render
// the value back into the form; the route is responsible for the read
// decryption. Here we only echo the two plaintext columns and a
// boolean presence flag for the encrypted KVNR.
fullName: string | null;
insurerName: string | null;
insurerIkNumber: string | null;
hasInsuranceNumber: boolean;
};
}
export interface ApplyProfileError {
ok: false;
status: number;
message: string;
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
* code so callers can shape the wire response themselves.
*/
export async function applyProfileUpdate(
userId: string,
body: unknown,
ipAddress?: string | null,
): Promise<ApplyProfileResult | ApplyProfileError> {
const parsed = extendedProfileSchema.safeParse(body);
if (!parsed.success) {
return {
ok: false,
status: 422,
message: parsed.error.issues[0]?.message ?? "Validation error",
};
}
const data = parsed.data;
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing && existing.id !== userId) {
return { ok: false, status: 409, message: "Email already in use" };
}
}
const updates: Record<string, unknown> = {};
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
updates.displayName =
data.displayName === null || data.displayName === ""
? null
: data.displayName.trim();
}
if (data.locale !== undefined) updates.locale = data.locale;
if (data.timezone !== undefined) updates.timezone = data.timezone;
if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat;
if (data.dateFormat !== undefined) updates.dateFormat = data.dateFormat;
if (data.moodReminderEnabled !== undefined) {
updates.moodReminderEnabled = data.moodReminderEnabled;
}
// v1.7.0 — patient-identity fields. Plaintext for fullName/insurerName;
// KVNR is encrypted at rest. An empty string collapses to null so the
// export cover + FHIR Patient omit the line entirely.
if (data.fullName !== undefined) {
updates.fullName =
data.fullName === null || data.fullName === "" ? null : data.fullName;
}
if (data.insurerName !== undefined) {
updates.insurerName =
data.insurerName === null || data.insurerName === ""
? null
: data.insurerName;
}
// v1.8.6 — IKNR plaintext (matches insurerName). The Zod transform
// already mapped empty → null, so a null here means "clear it".
if (data.insurerIkNumber !== undefined) {
updates.insurerIkNumber = data.insurerIkNumber ?? null;
}
if (data.insuranceNumber !== undefined) {
updates.insuranceNumberEncrypted =
data.insuranceNumber === null || data.insuranceNumber === ""
? null
: encrypt(data.insuranceNumber);
}
// v1.4.18 — capture the prior locale so we can emit a separate
// `settings.locale.update` audit row when the locale actually
// changes. The hidden "polyglot" achievement watches that action,
// and we don't want to fire it on profile updates that happen to
// touch other fields.
const priorLocale =
data.locale !== undefined
? ((
await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
})
)?.locale ?? null)
: null;
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updates,
});
// Anthropometrics feed the cached targets grid (BMI band, BP-by-age
// range, body-fat band), the derived-score baseline profile (age /
// sex / height) and the analytics snapshot. Only those three fields
// matter here — a display-name or email edit must not cold-rebuild
// the analytics caches.
if (
data.heightCm !== undefined ||
data.dateOfBirth !== undefined ||
data.gender !== undefined
) {
invalidateUserProfile(userId);
}
// v1.16.9 — the medication payloads (list next-due, today counts, the
// compliance day buckets) are computed in the user's timezone, but the
// list cache key carries only the userId. A timezone change must evict
// the bucket or the next read serves the previous zone's day grid.
if (data.timezone !== undefined) {
invalidateUserMedications(userId, { evict: true });
}
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
});
if (data.locale !== undefined && priorLocale !== data.locale) {
await auditLog("settings.locale.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
details: { from: priorLocale, to: data.locale ?? null },
});
}
return {
ok: true,
user: {
id: updatedUser.id,
username: updatedUser.username,
displayName: updatedUser.displayName,
email: updatedUser.email,
role: updatedUser.role,
heightCm: updatedUser.heightCm,
dateOfBirth: updatedUser.dateOfBirth,
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
timeFormat: updatedUser.timeFormat ?? "AUTO",
dateFormat: updatedUser.dateFormat ?? "AUTO",
moodReminderEnabled: updatedUser.moodReminderEnabled,
fullName: updatedUser.fullName,
insurerName: updatedUser.insurerName,
insurerIkNumber: updatedUser.insurerIkNumber,
hasInsuranceNumber: updatedUser.insuranceNumberEncrypted != null,
},
};
}