mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch surfaced enough blockers that the feature could not have been used safely as shipped: - Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English. - Dedup ledger committed before delivery — a transient APNs blip silently nuked the day's nudge and blocked retries. - DST-unsafe arithmetic drifted the local-22:00 baseline by an hour twice a year for both the mood-reminder push and the medication iOS-snooze action. - APNs payload forwarded the Telegram `replyMarkup` inline-keyboard object and other channel-specific extras to Apple's servers and the iOS notification handler. - No Settings UI for the per-user opt-in flag — the feature was unreachable for end users regardless of the other fixes. v1.4.38.2 closes every item above plus adds the missing `MoodReminderCard` Switch, a 90-day retention sweep for the dispatch ledger, and a per-user `try` wrapper so one bad row cannot abort the 22:00 tick. See CHANGELOG.md.
150 lines
4.5 KiB
TypeScript
150 lines
4.5 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 { 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(),
|
|
});
|
|
|
|
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
|
|
|
|
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;
|
|
moodReminderEnabled: 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.moodReminderEnabled !== undefined) {
|
|
updates.moodReminderEnabled = data.moodReminderEnabled;
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
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,
|
|
moodReminderEnabled: updatedUser.moodReminderEnabled,
|
|
},
|
|
};
|
|
}
|