mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
217 lines
7.3 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|