Files
HealthLog/src/lib/auth/profile-update.ts
T
Marc-André Bombeck aaecd141ee fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
2026-06-12 10:43:32 +02:00

217 lines
7.3 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(),
});
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";
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.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",
moodReminderEnabled: updatedUser.moodReminderEnabled,
fullName: updatedUser.fullName,
insurerName: updatedUser.insurerName,
insurerIkNumber: updatedUser.insurerIkNumber,
hasInsuranceNumber: updatedUser.insuranceNumberEncrypted != null,
},
};
}