mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
223 lines
7.7 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|