From a6524721da9a88abbdadec0aa58e47d4942bcc82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 12:11:44 +0200 Subject: [PATCH] fix(i18n): localise medication reminders and dashboard streak per user (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v3 audit "Locale Mixing" CRIT plus three of the cousin findings the v1.4 discovery audit added: 1. **`getPhaseMessage` and `getPhaseKeyboard` were German-only.** Every medication reminder shipped via Telegram, ntfy, and Web Push read "Erinnerung", "Bald fällig", etc. regardless of the recipient's locale. This is the user-facing surface most affected by the v3 "Locale Mixing" finding because the strings hit users on a 15-minute cron, not just an admin / settings page they might never open. The reminder worker now passes `med.user.locale` through; messages and button labels resolve from `messages/{de,en}.json` under `medicationReminders.*`. `callback_data` keys stay stable English identifiers so the bot dispatcher keeps matching them across locale changes. 2. **`/api/dashboard/summary` ships a hard-coded "Tage in Folge" label and "Hi, X" salutation.** Both now come from `dashboard.streakLabel` / `dashboard.greetingSalutation` resolved server-side against the user's locale. 3. **Mixed-locale Zod messages.** `validations/measurement.ts:69` had "Wert muss zwischen … liegen" while line 86 had "Value out of plausible range" — the response envelope leaked German into English sessions and vice versa. `validations/admin.ts` had four German messages (web-push subject, Umami URL, Glitchtip DSN, GitHub bug-report repo). All now resolve to a single English wording (the app is English-first; the German UI translates the field labels client-side, the server-side validation message is treated as a developer-facing string). Adds 13 new tests in `reminder-phases.test.ts` covering English, German, and unknown-locale fallbacks for messages and keyboards; updates `validations/measurement.test.ts` to match the unified English message. The audit's remaining items (`classifications.ts` server-returned EN category strings, `pulse-targets.ts`, `targets/route.ts` labels, `/api/admin/notifications/reminder-check`) ride a follow-up i18n PR — those are admin-only or client-renderable surfaces with lower user-facing priority. Tests: 358 → 371 (+13). Typecheck clean. Locale-integrity test still green. Co-authored-by: Marc-André Bombeck --- messages/de.json | 17 + messages/en.json | 17 + src/app/api/dashboard/summary/route.ts | 19 +- .../jobs/__tests__/reminder-phases.test.ts | 144 +++ src/lib/jobs/reminder-phases.ts | 93 +- src/lib/jobs/reminder-worker.ts | 1121 +++++++++-------- .../validations/__tests__/measurement.test.ts | 12 +- src/lib/validations/admin.ts | 8 +- src/lib/validations/measurement.ts | 4 +- 9 files changed, 862 insertions(+), 573 deletions(-) create mode 100644 src/lib/jobs/__tests__/reminder-phases.test.ts diff --git a/messages/de.json b/messages/de.json index 9fd019d32..2f6ef4df5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -194,6 +194,8 @@ }, "welcomeBack": "{greeting}, willkommen zurück.", "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", + "greetingSalutation": "Hi, {name}", + "streakLabel": "Tage in Folge", "weight": "Gewicht", "bloodPressure": "Blutdruck", "bloodPressureSys": "Blutdruck (Sys)", @@ -1629,5 +1631,20 @@ "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", "helpHeader": "Verfügbare Befehle:", "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen" + }, + "medicationReminders": { + "phaseGreenTitle": "🟢 Erinnerung: {medName}", + "phaseGreenMessage": "🟢 Erinnerung:\n{medName} ({doseInfo}, {timeWindow})\nZeitfenster endet in {minutes} Min.", + "phaseYellowTitle": "🟡 Bald fällig: {medName}", + "phaseYellowMessage": "🟡 Bald fällig:\n{medName} ({doseInfo}, {timeWindow})\nNoch {minutes} Min. Zeit.", + "phaseOrangeTitle": "🟠 Überfällig: {medName}", + "phaseOrangeMessage": "🟠 Überfällig:\n{medName} ({doseInfo}, {timeWindow})\nSeit {minutes} Min. überfällig.", + "phaseRedTitle": "🔴 Verpasst: {medName}", + "phaseRedMessage": "🔴 Verpasst:\n{medName} ({doseInfo}, {timeWindow})\nAls verpasst markiert.", + "buttonTaken": "Genommen", + "buttonConfirm": "✓ Bestätigen", + "buttonSnoozeOneHour": "🕐 1h", + "buttonSnoozeThreeHours": "🕐 3h", + "buttonSkip": "⏭ Überspringen" } } diff --git a/messages/en.json b/messages/en.json index 254524e7c..1abc97b46 100644 --- a/messages/en.json +++ b/messages/en.json @@ -194,6 +194,8 @@ }, "welcomeBack": "{greeting}, welcome back.", "welcomeBackWithName": "{greeting} {name}, welcome back.", + "greetingSalutation": "Hi, {name}", + "streakLabel": "days in a row", "weight": "Weight", "bloodPressure": "Blood Pressure", "bloodPressureSys": "Blood Pressure (Sys)", @@ -1629,5 +1631,20 @@ "medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.", "helpHeader": "Available commands:", "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake" + }, + "medicationReminders": { + "phaseGreenTitle": "🟢 Reminder: {medName}", + "phaseGreenMessage": "🟢 Reminder:\n{medName} ({doseInfo}, {timeWindow})\nWindow closes in {minutes} min.", + "phaseYellowTitle": "🟡 Due soon: {medName}", + "phaseYellowMessage": "🟡 Due soon:\n{medName} ({doseInfo}, {timeWindow})\n{minutes} min left.", + "phaseOrangeTitle": "🟠 Overdue: {medName}", + "phaseOrangeMessage": "🟠 Overdue:\n{medName} ({doseInfo}, {timeWindow})\nOverdue by {minutes} min.", + "phaseRedTitle": "🔴 Missed: {medName}", + "phaseRedMessage": "🔴 Missed:\n{medName} ({doseInfo}, {timeWindow})\nMarked as missed.", + "buttonTaken": "Taken", + "buttonConfirm": "✓ Confirm", + "buttonSnoozeOneHour": "🕐 1h", + "buttonSnoozeThreeHours": "🕐 3h", + "buttonSkip": "⏭ Skip" } } diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 959b6b977..4eceedfa6 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -15,6 +15,8 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -159,8 +161,8 @@ export const GET = apiHandler(async () => { ...measurementTypeEnum.options, ] as MeasurementType[]; - const [recentMeasurements, todaysIntakes, streakActivity] = - await Promise.all([ + const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all( + [ prisma.measurement.findMany({ where: { userId: user.id, @@ -185,10 +187,12 @@ export const GET = apiHandler(async () => { }, select: { takenAt: true, scheduledFor: true }, }), - ]); + ], + ); const activityDays = new Set(); - for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt)); + for (const m of recentMeasurements) + activityDays.add(berlinDayKey(m.measuredAt)); // Pull a wider measurement window for the streak so it isn't capped by // SPARK_DAYS — but only if the user has any data at all. if (recentMeasurements.length > 0) { @@ -333,16 +337,19 @@ export const GET = apiHandler(async () => { ).length; const greetingName = user.displayName ?? user.username; + const locale: Locale = + user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale; + const t = getServerTranslator(locale).t; return apiSuccess({ greeting: { - salutation: `Hi, ${greetingName}`, + salutation: t("dashboard.greetingSalutation", { name: greetingName }), date: now.toISOString(), }, streak: { currentDays: streak.currentDays, longest: streak.longest, - label: "Tage in Folge", + label: t("dashboard.streakLabel"), }, compliance: { scheduledToday, diff --git a/src/lib/jobs/__tests__/reminder-phases.test.ts b/src/lib/jobs/__tests__/reminder-phases.test.ts new file mode 100644 index 000000000..8a7b38263 --- /dev/null +++ b/src/lib/jobs/__tests__/reminder-phases.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; + +import { + determinePhase, + getPhaseKeyboard, + getPhaseMessage, + resolvePhaseThresholds, + DEFAULT_PHASE_CONFIG, +} from "../reminder-phases"; + +describe("determinePhase", () => { + const thresholds = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 60); + + it("returns RED past the red threshold", () => { + expect(determinePhase(-300, 360, thresholds)).toBe("RED"); + }); + + it("returns ORANGE between window end and red", () => { + expect(determinePhase(-30, 90, thresholds)).toBe("ORANGE"); + }); + + it("returns YELLOW within yellowMinBefore of window end", () => { + expect(determinePhase(20, 40, thresholds)).toBe("YELLOW"); + }); + + it("returns GREEN inside green window when at/after window start", () => { + expect(determinePhase(50, 10, thresholds)).toBe("GREEN"); + }); + + it("returns null before window start", () => { + expect(determinePhase(80, -20, thresholds)).toBe(null); + }); +}); + +// v1.4 marathon — closes v3 audit "Locale Mixing" CRIT. +// Reminder messages must follow the user's stored locale, not always +// German. callback_data values are stable English identifiers and stay +// untranslated regardless of UI locale. +describe("getPhaseMessage — localised", () => { + it("uses German message + title for de locale", () => { + const result = getPhaseMessage( + "GREEN", + "Ramipril", + "10mg", + "08:00–09:00", + 45, + "de", + ); + expect(result.title).toBe("🟢 Erinnerung: Ramipril"); + expect(result.message).toContain("Zeitfenster endet in 45 Min."); + expect(result.message).toContain("Ramipril"); + }); + + it("uses English message + title for en locale", () => { + const result = getPhaseMessage( + "YELLOW", + "Ramipril", + "10mg", + "08:00–09:00", + 20, + "en", + ); + expect(result.title).toBe("🟡 Due soon: Ramipril"); + expect(result.message).toContain("20 min left."); + expect(result.message).toContain("Ramipril"); + }); + + it("falls back to default locale (en) for unknown / missing locale", () => { + const result = getPhaseMessage( + "ORANGE", + "Aspirin", + "100mg", + "12:00–13:00", + -90, + null, + ); + expect(result.title).toBe("🟠 Overdue: Aspirin"); + expect(result.message).toContain("Overdue by 90 min."); + }); + + it("RED phase template suppresses the per-message minutes count", () => { + const result = getPhaseMessage( + "RED", + "Aspirin", + "100mg", + "12:00–13:00", + -300, + "en", + ); + expect(result.message).toContain("Marked as missed."); + expect(result.message).not.toContain("300"); + }); +}); + +describe("getPhaseKeyboard — localised", () => { + it("returns German labels for de locale", () => { + const kb = getPhaseKeyboard("YELLOW", "med-1", "de"); + const flat = kb.inline_keyboard.flat(); + expect(flat.find((b) => b.callback_data === "taken:med-1")?.text).toBe( + "Genommen", + ); + expect(flat.find((b) => b.callback_data === "snooze:med-1:60")?.text).toBe( + "🕐 1h", + ); + expect(flat.find((b) => b.callback_data === "skip:med-1")?.text).toBe( + "⏭ Überspringen", + ); + }); + + it("returns English labels for en locale", () => { + const kb = getPhaseKeyboard("YELLOW", "med-1", "en"); + const flat = kb.inline_keyboard.flat(); + expect(flat.find((b) => b.callback_data === "taken:med-1")?.text).toBe( + "Taken", + ); + expect(flat.find((b) => b.callback_data === "skip:med-1")?.text).toBe( + "⏭ Skip", + ); + }); + + it("RED phase has Confirm button localised", () => { + expect( + getPhaseKeyboard("RED", "med-1", "en").inline_keyboard[0].find( + (b) => b.callback_data === "ack:med-1", + )?.text, + ).toBe("✓ Confirm"); + expect( + getPhaseKeyboard("RED", "med-1", "de").inline_keyboard[0].find( + (b) => b.callback_data === "ack:med-1", + )?.text, + ).toBe("✓ Bestätigen"); + }); + + it("callback_data values are NEVER translated (stable contract)", () => { + for (const locale of ["de", "en", null, "fr"] as const) { + const kb = getPhaseKeyboard("YELLOW", "med-x", locale); + const cb = kb.inline_keyboard.flat().map((b) => b.callback_data); + expect(cb).toContain("taken:med-x"); + expect(cb).toContain("snooze:med-x:60"); + expect(cb).toContain("snooze:med-x:180"); + expect(cb).toContain("skip:med-x"); + } + }); +}); diff --git a/src/lib/jobs/reminder-phases.ts b/src/lib/jobs/reminder-phases.ts index 9fa9f5723..24f44f9db 100644 --- a/src/lib/jobs/reminder-phases.ts +++ b/src/lib/jobs/reminder-phases.ts @@ -4,6 +4,9 @@ * current time relative to the schedule window end. */ +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + export type ReminderPhase = "GREEN" | "YELLOW" | "ORANGE" | "RED"; export type PhaseMode = "MINUTES" | "PERCENT"; @@ -115,8 +118,18 @@ export function determinePhase( return null; // Not in any phase yet } +function resolveLocale(locale: Locale | string | null | undefined): Locale { + return locale === "en" || locale === "de" ? locale : defaultLocale; +} + /** - * Get the message template for a phase. + * Get the message template for a phase, localised to the recipient + * user. Strings live in `messages/{de,en}.json` under + * `medicationReminders.phase*`. The reminder worker passes the user's + * stored locale; absent / unknown locales fall back to the app default. + * + * v1.4 marathon — closes the v3 audit "Locale-Mixing" CRIT finding + * (German-only Telegram / Web Push templates regardless of user locale). */ export function getPhaseMessage( phase: ReminderPhase, @@ -124,47 +137,79 @@ export function getPhaseMessage( doseInfo: string, timeWindow: string, minutesToEnd: number, + locale: Locale | string | null | undefined, ): { title: string; message: string } { - const minutesPastEnd = -minutesToEnd; + const t = getServerTranslator(resolveLocale(locale)).t; const absMinutes = Math.abs(minutesToEnd); + const minutesPastEnd = Math.max(0, -minutesToEnd); + + const params = (extra: Record) => ({ + medName, + doseInfo, + timeWindow, + ...extra, + }); switch (phase) { case "GREEN": return { - title: `🟢 Erinnerung: ${medName}`, - message: `🟢 Erinnerung:\n${medName} (${doseInfo}, ${timeWindow})\nZeitfenster endet in ${absMinutes} Min.`, + title: t("medicationReminders.phaseGreenTitle", { medName }), + message: t( + "medicationReminders.phaseGreenMessage", + params({ minutes: absMinutes }), + ), }; case "YELLOW": return { - title: `🟡 Bald fällig: ${medName}`, - message: `🟡 Bald fällig:\n${medName} (${doseInfo}, ${timeWindow})\nNoch ${absMinutes} Min. Zeit.`, + title: t("medicationReminders.phaseYellowTitle", { medName }), + message: t( + "medicationReminders.phaseYellowMessage", + params({ minutes: absMinutes }), + ), }; case "ORANGE": return { - title: `🟠 Überfällig: ${medName}`, - message: `🟠 Überfällig:\n${medName} (${doseInfo}, ${timeWindow})\nSeit ${minutesPastEnd} Min. überfällig.`, + title: t("medicationReminders.phaseOrangeTitle", { medName }), + message: t( + "medicationReminders.phaseOrangeMessage", + params({ minutes: minutesPastEnd }), + ), }; case "RED": return { - title: `🔴 Verpasst: ${medName}`, - message: `🔴 Verpasst:\n${medName} (${doseInfo}, ${timeWindow})\nAls verpasst markiert.`, + title: t("medicationReminders.phaseRedTitle", { medName }), + message: t( + "medicationReminders.phaseRedMessage", + params({ minutes: 0 }), + ), }; } } /** - * Get the inline keyboard for a phase. + * Get the inline keyboard for a phase. Button labels follow the user's + * locale; callback_data values are stable English identifiers and must + * NOT be translated (the bot dispatcher matches on them). */ export function getPhaseKeyboard( phase: ReminderPhase, medicationId: string, + locale: Locale | string | null | undefined, ): { inline_keyboard: { text: string; callback_data: string }[][] } { + const t = getServerTranslator(resolveLocale(locale)).t; + if (phase === "RED") { return { inline_keyboard: [ [ - { text: "Genommen", callback_data: `taken:${medicationId}` }, - { text: "✓ Bestätigen", callback_data: `ack:${medicationId}` }, + { + text: t("medicationReminders.buttonTaken"), + callback_data: `taken:${medicationId}`, + }, + { + text: t("medicationReminders.buttonConfirm"), + callback_data: `ack:${medicationId}`, + }, ], ], }; @@ -172,11 +217,25 @@ export function getPhaseKeyboard( return { inline_keyboard: [ - [{ text: "Genommen", callback_data: `taken:${medicationId}` }], [ - { text: "🕐 1h", callback_data: `snooze:${medicationId}:60` }, - { text: "🕐 3h", callback_data: `snooze:${medicationId}:180` }, - { text: "⏭ Überspringen", callback_data: `skip:${medicationId}` }, + { + text: t("medicationReminders.buttonTaken"), + callback_data: `taken:${medicationId}`, + }, + ], + [ + { + text: t("medicationReminders.buttonSnoozeOneHour"), + callback_data: `snooze:${medicationId}:60`, + }, + { + text: t("medicationReminders.buttonSnoozeThreeHours"), + callback_data: `snooze:${medicationId}:180`, + }, + { + text: t("medicationReminders.buttonSkip"), + callback_data: `skip:${medicationId}`, + }, ], ], }; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 49b90261f..f07e67544 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -242,213 +242,232 @@ async function cleanupScheduledTelegramDeletions(): Promise { async function handleReminderCheck(jobs: Job[]) { void jobs; await withBackgroundEvent("job.medication_reminder", async (evt) => { - const prisma = getWorkerPrisma(); - try { - recordReminderCheck(); - const now = new Date(); + const prisma = getWorkerPrisma(); + try { + recordReminderCheck(); + const now = new Date(); - // Clean up expired scheduled Telegram message deletions - await cleanupScheduledTelegramDeletions(); + // Clean up expired scheduled Telegram message deletions + await cleanupScheduledTelegramDeletions(); - // Clean up expired snoozes - await prisma.medication.updateMany({ - where: { snoozedUntil: { lt: now } }, - data: { snoozedUntil: null }, - }); + // Clean up expired snoozes + await prisma.medication.updateMany({ + where: { snoozedUntil: { lt: now } }, + data: { snoozedUntil: null }, + }); - // Get all active medications with schedules and phase config - const medications = await prisma.medication.findMany({ - where: { active: true }, - include: { - schedules: true, - phaseConfig: true, - user: { - select: { - id: true, - timezone: true, + // Get all active medications with schedules and phase config + const medications = await prisma.medication.findMany({ + where: { active: true }, + include: { + schedules: true, + phaseConfig: true, + user: { + select: { + id: true, + timezone: true, + // Used to localise the reminder title / message / keyboard + // labels per user. Null falls back to the app default. + locale: true, + }, }, }, - }, - }); - - for (const med of medications) { - const userTz = med.user.timezone || "Europe/Berlin"; - const { start: todayStart, end: todayEnd } = getUserTodayBounds( - now, - userTz, - ); - - const currentTime = now.toLocaleTimeString("en-GB", { - timeZone: userTz, - hour: "2-digit", - minute: "2-digit", - hour12: false, }); - const todayDow = getDayOfWeekInTz(now, userTz); - - // Get today's date string in user's timezone for message tracking - const localDateStr = now.toLocaleDateString("sv-SE", { - timeZone: userTz, - }); // YYYY-MM-DD format - - // Count existing intake events for this medication today - const eventCount = await prisma.medicationIntakeEvent.count({ - where: { - medicationId: med.id, - userId: med.user.id, - scheduledFor: { gte: todayStart, lte: todayEnd }, - }, - }); - - // Resolve phase configuration - const phaseConfig = med.phaseConfig ?? DEFAULT_PHASE_CONFIG; - - let schedulesProcessed = 0; - const sortedSchedules = [...med.schedules].sort((a, b) => - a.windowStart.localeCompare(b.windowStart), - ); - - for (const schedule of sortedSchedules) { - // Check day-of-week / recurrence constraints - const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); - if ( - recurrence.daysOfWeek.length > 0 && - !recurrence.daysOfWeek.includes(todayDow) - ) { - continue; - } - - const startMins = parseTimeToMinutes(schedule.windowStart); - const endMins = parseTimeToMinutes(schedule.windowEnd); - const currentMins = parseTimeToMinutes(currentTime); - const windowDuration = endMins - startMins; - const minutesToEnd = endMins - currentMins; - const minutesFromStart = currentMins - startMins; - - // Skip if enough intake events exist - if (eventCount > schedulesProcessed) { - schedulesProcessed++; - continue; - } - - // Skip if medication is snoozed - if (med.snoozedUntil && now < med.snoozedUntil) { - schedulesProcessed++; - continue; - } - - // Resolve phase thresholds - const thresholds = resolvePhaseThresholds(phaseConfig, windowDuration); - - // Determine current phase - const currentPhase = determinePhase( - minutesToEnd, - minutesFromStart, - thresholds, + for (const med of medications) { + const userTz = med.user.timezone || "Europe/Berlin"; + const { start: todayStart, end: todayEnd } = getUserTodayBounds( + now, + userTz, ); - if (!currentPhase) { - schedulesProcessed++; - continue; - } + const currentTime = now.toLocaleTimeString("en-GB", { + timeZone: userTz, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); - // Check if this phase was already notified today - const existingMessage = - await prisma.telegramReminderMessage.findUnique({ - where: { - medicationId_scheduleId_date_phase: { - medicationId: med.id, - scheduleId: schedule.id, - date: localDateStr, - phase: currentPhase, - }, - }, - }); + const todayDow = getDayOfWeekInTz(now, userTz); - if (existingMessage) { - // Already sent for this phase — skip - schedulesProcessed++; - continue; - } + // Get today's date string in user's timezone for message tracking + const localDateStr = now.toLocaleDateString("sv-SE", { + timeZone: userTz, + }); // YYYY-MM-DD format - const doseInfo = schedule.dose ?? med.dose; - const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`; + // Count existing intake events for this medication today + const eventCount = await prisma.medicationIntakeEvent.count({ + where: { + medicationId: med.id, + userId: med.user.id, + scheduledFor: { gte: todayStart, lte: todayEnd }, + }, + }); - // RED phase: create missed intake event - if (currentPhase === "RED") { - const [h, m] = schedule.windowStart.split(":").map(Number); - const scheduledFor = new Date( - todayStart.getTime() + h * 3600000 + m * 60000, + // Resolve phase configuration + const phaseConfig = med.phaseConfig ?? DEFAULT_PHASE_CONFIG; + + let schedulesProcessed = 0; + const sortedSchedules = [...med.schedules].sort((a, b) => + a.windowStart.localeCompare(b.windowStart), + ); + + for (const schedule of sortedSchedules) { + // Check day-of-week / recurrence constraints + const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); + if ( + recurrence.daysOfWeek.length > 0 && + !recurrence.daysOfWeek.includes(todayDow) + ) { + continue; + } + + const startMins = parseTimeToMinutes(schedule.windowStart); + const endMins = parseTimeToMinutes(schedule.windowEnd); + const currentMins = parseTimeToMinutes(currentTime); + const windowDuration = endMins - startMins; + const minutesToEnd = endMins - currentMins; + const minutesFromStart = currentMins - startMins; + + // Skip if enough intake events exist + if (eventCount > schedulesProcessed) { + schedulesProcessed++; + continue; + } + + // Skip if medication is snoozed + if (med.snoozedUntil && now < med.snoozedUntil) { + schedulesProcessed++; + continue; + } + + // Resolve phase thresholds + const thresholds = resolvePhaseThresholds( + phaseConfig, + windowDuration, ); - const existingMissed = await prisma.medicationIntakeEvent.count({ - where: { - medicationId: med.id, - userId: med.user.id, - scheduledFor, - takenAt: null, - source: "REMINDER", - }, - }); + // Determine current phase + const currentPhase = determinePhase( + minutesToEnd, + minutesFromStart, + thresholds, + ); - if (existingMissed === 0) { - await prisma.medicationIntakeEvent.create({ - data: { - userId: med.user.id, + if (!currentPhase) { + schedulesProcessed++; + continue; + } + + // Check if this phase was already notified today + const existingMessage = + await prisma.telegramReminderMessage.findUnique({ + where: { + medicationId_scheduleId_date_phase: { + medicationId: med.id, + scheduleId: schedule.id, + date: localDateStr, + phase: currentPhase, + }, + }, + }); + + if (existingMessage) { + // Already sent for this phase — skip + schedulesProcessed++; + continue; + } + + const doseInfo = schedule.dose ?? med.dose; + const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`; + + // RED phase: create missed intake event + if (currentPhase === "RED") { + const [h, m] = schedule.windowStart.split(":").map(Number); + const scheduledFor = new Date( + todayStart.getTime() + h * 3600000 + m * 60000, + ); + + const existingMissed = await prisma.medicationIntakeEvent.count({ + where: { medicationId: med.id, + userId: med.user.id, scheduledFor, takenAt: null, - skipped: false, source: "REMINDER", }, }); - evt.addMeta("missed_dose", `${med.name}:${schedule.windowStart}-${schedule.windowEnd}`); + if (existingMissed === 0) { + await prisma.medicationIntakeEvent.create({ + data: { + userId: med.user.id, + medicationId: med.id, + scheduledFor, + takenAt: null, + skipped: false, + source: "REMINDER", + }, + }); + + evt.addMeta( + "missed_dose", + `${med.name}:${schedule.windowStart}-${schedule.windowEnd}`, + ); + } } - } - // Send notification if enabled - if (med.notificationsEnabled) { - const { title, message } = getPhaseMessage( - currentPhase, - med.name, - doseInfo, - timeWindow, - minutesToEnd, - ); + // Send notification if enabled + if (med.notificationsEnabled) { + const { title, message } = getPhaseMessage( + currentPhase, + med.name, + doseInfo, + timeWindow, + minutesToEnd, + med.user.locale, + ); - const keyboard = getPhaseKeyboard(currentPhase, med.id); + const keyboard = getPhaseKeyboard( + currentPhase, + med.id, + med.user.locale, + ); - evt.addMeta("notification_phase", `${currentPhase}:${med.name}:${schedule.windowStart}-${schedule.windowEnd}`); + evt.addMeta( + "notification_phase", + `${currentPhase}:${med.name}:${schedule.windowStart}-${schedule.windowEnd}`, + ); - try { - await dispatchNotification({ - eventType: "MEDICATION_REMINDER", - userId: med.user.id, - title, - message, - metadata: { - medicationId: med.id, - scheduleId: schedule.id, - phase: currentPhase, - date: localDateStr, - replyMarkup: keyboard, - }, - }); - } catch (notifErr) { - evt.addWarning(`Notification dispatch failed for ${currentPhase} phase ${med.name}: ${notifErr}`); + try { + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: med.user.id, + title, + message, + metadata: { + medicationId: med.id, + scheduleId: schedule.id, + phase: currentPhase, + date: localDateStr, + replyMarkup: keyboard, + }, + }); + } catch (notifErr) { + evt.addWarning( + `Notification dispatch failed for ${currentPhase} phase ${med.name}: ${notifErr}`, + ); + } } - } - schedulesProcessed++; + schedulesProcessed++; + } } + } catch (err) { + evt.setError(err); + recordError(); + throw err; } - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } }); } @@ -459,79 +478,87 @@ async function handleReminderCheck(jobs: Job[]) { async function handleWithingsFallbackSync(jobs: Job[]) { void jobs; await withBackgroundEvent("job.withings_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - recordWithingsSync(); - const connections = await prisma.withingsConnection.findMany({ - select: { userId: true }, - }); + const prisma = getWorkerPrisma(); + try { + recordWithingsSync(); + const connections = await prisma.withingsConnection.findMany({ + select: { userId: true }, + }); - if (connections.length === 0) { - return; - } - - let usersSynced = 0; - let measurementsImported = 0; - - for (const connection of connections) { - try { - const imported = await syncUserMeasurements(connection.userId); - usersSynced++; - measurementsImported += imported; - } catch (err) { - evt.addWarning(`Fallback sync failed for user ${connection.userId}: ${err}`); + if (connections.length === 0) { + return; } - } - evt.setBackground({ - task_name: "job.withings_sync", - result: { users_synced: usersSynced, total: connections.length, measurements_imported: measurementsImported }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + let usersSynced = 0; + let measurementsImported = 0; + + for (const connection of connections) { + try { + const imported = await syncUserMeasurements(connection.userId); + usersSynced++; + measurementsImported += imported; + } catch (err) { + evt.addWarning( + `Fallback sync failed for user ${connection.userId}: ${err}`, + ); + } + } + + evt.setBackground({ + task_name: "job.withings_sync", + result: { + users_synced: usersSynced, + total: connections.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } async function handleGeneralStatusGenerate(jobs: Job[]) { void jobs; await withBackgroundEvent("job.insights.general", async (evt) => { - const prisma = getWorkerPrisma(); - try { - recordInsightsRun(); - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + const prisma = getWorkerPrisma(); + try { + recordInsightsRun(); + const users = await prisma.user.findMany({ + select: { id: true, locale: true }, + }); - if (users.length === 0) return; + if (users.length === 0) return; - let generated = 0; - let failed = 0; + let generated = 0; + let failed = 0; - for (const user of users) { - try { - await generateGeneralStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning(`general-status generation failed for user ${user.id}: ${error}`); + for (const user of users) { + try { + await generateGeneralStatusForUser(user.id, { + locale: user.locale ?? "de", + force: false, + }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `general-status generation failed for user ${user.id}: ${error}`, + ); + } } - } - evt.setBackground({ - task_name: "job.insights.general", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + evt.setBackground({ + task_name: "job.insights.general", + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } @@ -540,156 +567,164 @@ async function handleBloodPressureStatusGenerate( ) { void jobs; await withBackgroundEvent("job.insights.blood_pressure", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + const prisma = getWorkerPrisma(); + try { + const users = await prisma.user.findMany({ + select: { id: true, locale: true }, + }); - if (users.length === 0) return; + if (users.length === 0) return; - let generated = 0; - let failed = 0; + let generated = 0; + let failed = 0; - for (const user of users) { - try { - await generateBloodPressureStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning(`blood-pressure-status generation failed for user ${user.id}: ${error}`); + for (const user of users) { + try { + await generateBloodPressureStatusForUser(user.id, { + locale: user.locale ?? "de", + force: false, + }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `blood-pressure-status generation failed for user ${user.id}: ${error}`, + ); + } } - } - evt.setBackground({ - task_name: "job.insights.blood_pressure", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + evt.setBackground({ + task_name: "job.insights.blood_pressure", + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } async function handleWeightStatusGenerate(jobs: Job[]) { void jobs; await withBackgroundEvent("job.insights.weight", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + const prisma = getWorkerPrisma(); + try { + const users = await prisma.user.findMany({ + select: { id: true, locale: true }, + }); - if (users.length === 0) return; + if (users.length === 0) return; - let generated = 0; - let failed = 0; + let generated = 0; + let failed = 0; - for (const user of users) { - try { - await generateWeightStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning(`weight-status generation failed for user ${user.id}: ${error}`); + for (const user of users) { + try { + await generateWeightStatusForUser(user.id, { + locale: user.locale ?? "de", + force: false, + }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `weight-status generation failed for user ${user.id}: ${error}`, + ); + } } - } - evt.setBackground({ - task_name: "job.insights.weight", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + evt.setBackground({ + task_name: "job.insights.weight", + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } async function handlePulseStatusGenerate(jobs: Job[]) { void jobs; await withBackgroundEvent("job.insights.pulse", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + const prisma = getWorkerPrisma(); + try { + const users = await prisma.user.findMany({ + select: { id: true, locale: true }, + }); - if (users.length === 0) return; + if (users.length === 0) return; - let generated = 0; - let failed = 0; + let generated = 0; + let failed = 0; - for (const user of users) { - try { - await generatePulseStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning(`pulse-status generation failed for user ${user.id}: ${error}`); + for (const user of users) { + try { + await generatePulseStatusForUser(user.id, { + locale: user.locale ?? "de", + force: false, + }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `pulse-status generation failed for user ${user.id}: ${error}`, + ); + } } - } - evt.setBackground({ - task_name: "job.insights.pulse", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + evt.setBackground({ + task_name: "job.insights.pulse", + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } async function handleBmiStatusGenerate(jobs: Job[]) { void jobs; await withBackgroundEvent("job.insights.bmi", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + const prisma = getWorkerPrisma(); + try { + const users = await prisma.user.findMany({ + select: { id: true, locale: true }, + }); - if (users.length === 0) return; + if (users.length === 0) return; - let generated = 0; - let failed = 0; + let generated = 0; + let failed = 0; - for (const user of users) { - try { - await generateBmiStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning(`bmi-status generation failed for user ${user.id}: ${error}`); + for (const user of users) { + try { + await generateBmiStatusForUser(user.id, { + locale: user.locale ?? "de", + force: false, + }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `bmi-status generation failed for user ${user.id}: ${error}`, + ); + } } - } - evt.setBackground({ - task_name: "job.insights.bmi", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + evt.setBackground({ + task_name: "job.insights.bmi", + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } @@ -697,41 +732,46 @@ async function handleMedicationComplianceStatusGenerate( jobs: Job[], ) { void jobs; - await withBackgroundEvent("job.insights.medication_compliance", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { + await withBackgroundEvent( + "job.insights.medication_compliance", + async (evt) => { + const prisma = getWorkerPrisma(); try { - await generateMedicationComplianceStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, + const users = await prisma.user.findMany({ + select: { id: true, locale: true }, }); - generated++; - } catch (error) { - failed++; - evt.addWarning(`medication-compliance-status generation failed for user ${user.id}: ${error}`); - } - } - evt.setBackground({ - task_name: "job.insights.medication_compliance", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + if (users.length === 0) return; + + let generated = 0; + let failed = 0; + + for (const user of users) { + try { + await generateMedicationComplianceStatusForUser(user.id, { + locale: user.locale ?? "de", + force: false, + }); + generated++; + } catch (error) { + failed++; + evt.addWarning( + `medication-compliance-status generation failed for user ${user.id}: ${error}`, + ); + } + } + + evt.setBackground({ + task_name: "job.insights.medication_compliance", + result: { generated, failed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }, + ); } /** @@ -740,28 +780,28 @@ async function handleMedicationComplianceStatusGenerate( */ async function handleTelegramCleanup(jobs: Job[]) { await withBackgroundEvent("job.telegram_cleanup", async (evt) => { - const prisma = getWorkerPrisma(); - let deleted = 0; - for (const job of jobs) { - try { - const { userId, chatId, messageId } = job.data; - const user = await prisma.user.findFirst({ - where: { id: userId, telegramBotToken: { not: null } }, - select: { telegramBotToken: true }, - }); - if (user?.telegramBotToken) { - const botToken = decrypt(user.telegramBotToken); - await deleteMessage(botToken, chatId, messageId); - deleted++; + const prisma = getWorkerPrisma(); + let deleted = 0; + for (const job of jobs) { + try { + const { userId, chatId, messageId } = job.data; + const user = await prisma.user.findFirst({ + where: { id: userId, telegramBotToken: { not: null } }, + select: { telegramBotToken: true }, + }); + if (user?.telegramBotToken) { + const botToken = decrypt(user.telegramBotToken); + await deleteMessage(botToken, chatId, messageId); + deleted++; + } + } catch (err) { + evt.addWarning(`Failed to delete message: ${err}`); } - } catch (err) { - evt.addWarning(`Failed to delete message: ${err}`); } - } - evt.setBackground({ - task_name: "job.telegram_cleanup", - result: { deleted, total: jobs.length }, - }); + evt.setBackground({ + task_name: "job.telegram_cleanup", + result: { deleted, total: jobs.length }, + }); }); } @@ -772,53 +812,55 @@ async function handleTelegramCleanup(jobs: Job[]) { async function handleMoodLogSync(jobs: Job[]) { void jobs; await withBackgroundEvent("job.moodlog_sync", async (evt) => { - const prisma = getWorkerPrisma(); - try { - // Check global toggle - const appSettings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { moodLogGlobal: true }, - }); - if (appSettings && !appSettings.moodLogGlobal) { - evt.addMeta("skipped", "global_toggle_disabled"); - return; - } - - const users = await prisma.user.findMany({ - where: { moodLogEnabled: true }, - select: { id: true }, - }); - - if (users.length === 0) return; - - let synced = 0; - let totalImported = 0; - - for (const user of users) { - try { - const imported = await syncMoodLogEntries(user.id); - synced++; - totalImported += imported; - } catch (err) { - evt.addWarning(`Fallback sync failed for user ${user.id}: ${err}`); + const prisma = getWorkerPrisma(); + try { + // Check global toggle + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moodLogGlobal: true }, + }); + if (appSettings && !appSettings.moodLogGlobal) { + evt.addMeta("skipped", "global_toggle_disabled"); + return; } - } - evt.setBackground({ - task_name: "job.moodlog_sync", - result: { synced, total: users.length, entries_imported: totalImported }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + const users = await prisma.user.findMany({ + where: { moodLogEnabled: true }, + select: { id: true }, + }); + + if (users.length === 0) return; + + let synced = 0; + let totalImported = 0; + + for (const user of users) { + try { + const imported = await syncMoodLogEntries(user.id); + synced++; + totalImported += imported; + } catch (err) { + evt.addWarning(`Fallback sync failed for user ${user.id}: ${err}`); + } + } + + evt.setBackground({ + task_name: "job.moodlog_sync", + result: { + synced, + total: users.length, + entries_imported: totalImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } -async function handleRateLimitCleanup( - jobs: Job[], -) { +async function handleRateLimitCleanup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => { const p = getWorkerPrisma(); @@ -864,107 +906,107 @@ async function handleAuditLogCleanup(jobs: Job[]) { async function handleDataBackup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.data_backup", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, username: true }, - }); + const prisma = getWorkerPrisma(); + try { + const users = await prisma.user.findMany({ + select: { id: true, username: true }, + }); - let backed = 0; - for (const user of users) { - try { - const [measurements, medications, intakeEvents, moodEntries] = - await Promise.all([ - prisma.measurement.findMany({ - where: { userId: user.id }, - orderBy: { measuredAt: "desc" }, - }), - prisma.medication.findMany({ - where: { userId: user.id }, - include: { schedules: true }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId: user.id }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: { userId: user.id }, - orderBy: { moodLoggedAt: "desc" }, - }), - ]); + let backed = 0; + for (const user of users) { + try { + const [measurements, medications, intakeEvents, moodEntries] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId: user.id }, + orderBy: { measuredAt: "desc" }, + }), + prisma.medication.findMany({ + where: { userId: user.id }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId: user.id }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: { userId: user.id }, + orderBy: { moodLoggedAt: "desc" }, + }), + ]); - const backupJson = JSON.stringify({ - exportedAt: new Date().toISOString(), - userId: user.id, - measurements: measurements.map((m) => ({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - notes: m.notes, - })), - medications: medications.map((m) => ({ - name: m.name, - dose: m.dose, - active: m.active, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - dose: s.dose, - })), - })), - intakeEvents: intakeEvents.map((e) => ({ - medication: e.medication.name, - scheduledFor: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? null, - skipped: e.skipped, - source: e.source, - })), - moodEntries: moodEntries.map((e) => ({ - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - })), - }); - - // Encrypt the backup data (contains sensitive health information) - const encryptedBackup = encrypt(backupJson); - - await prisma.dataBackup.upsert({ - where: { - userId_type: { userId: user.id, type: "WEEKLY_AUTO" }, - }, - update: { - data: encryptedBackup, - createdAt: new Date(), - }, - create: { + const backupJson = JSON.stringify({ + exportedAt: new Date().toISOString(), userId: user.id, - type: "WEEKLY_AUTO", - data: encryptedBackup, - }, - }); - backed++; - } catch (err) { - evt.addWarning(`Failed for user ${user.id}: ${err}`); - } - } + measurements: measurements.map((m) => ({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: m.notes, + })), + medications: medications.map((m) => ({ + name: m.name, + dose: m.dose, + active: m.active, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + dose: s.dose, + })), + })), + intakeEvents: intakeEvents.map((e) => ({ + medication: e.medication.name, + scheduledFor: e.scheduledFor.toISOString(), + takenAt: e.takenAt?.toISOString() ?? null, + skipped: e.skipped, + source: e.source, + })), + moodEntries: moodEntries.map((e) => ({ + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags, + source: e.source, + loggedAt: e.moodLoggedAt.toISOString(), + })), + }); - evt.setBackground({ - task_name: "job.data_backup", - result: { backed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } + // Encrypt the backup data (contains sensitive health information) + const encryptedBackup = encrypt(backupJson); + + await prisma.dataBackup.upsert({ + where: { + userId_type: { userId: user.id, type: "WEEKLY_AUTO" }, + }, + update: { + data: encryptedBackup, + createdAt: new Date(), + }, + create: { + userId: user.id, + type: "WEEKLY_AUTO", + data: encryptedBackup, + }, + }); + backed++; + } catch (err) { + evt.addWarning(`Failed for user ${user.id}: ${err}`); + } + } + + evt.setBackground({ + task_name: "job.data_backup", + result: { backed, total: users.length }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } }); } @@ -1023,7 +1065,10 @@ export async function startReminderWorker() { }, }); if (rotated > 0) { - workerLog("error", `moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`); + workerLog( + "error", + `moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`, + ); } } catch (err) { workerLog("error", `moodlog-secret-migration failed: ${err}`); diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 6eff3f10c..87c0b92b9 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -27,18 +27,18 @@ describe("measurement validation", () => { describe("validateMeasurementRange", () => { it("rejects values below the plausible minimum", () => { - expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/zwischen/); - expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/zwischen/); + expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/between/i); + expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/between/i); expect(validateMeasurementRange("TOTAL_BODY_WATER", 1)).toMatch( - /zwischen/, + /between/i, ); }); it("rejects values above the plausible maximum", () => { - expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/zwischen/); - expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/zwischen/); + expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/between/i); + expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/between/i); expect(validateMeasurementRange("TOTAL_BODY_WATER", 200)).toMatch( - /zwischen/, + /between/i, ); }); diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 1f96c4d9e..243f85b19 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -16,7 +16,7 @@ export const adminSettingsSchema = z .string() .refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), { message: - "Web Push Subject muss im Format mailto:adresse@example.com sein", + "webPushVapidSubject must be in mailto:address@example.com format", }) .optional(), webPushVapidPrivateKey: z.string().optional(), @@ -36,7 +36,7 @@ export const adminSettingsSchema = z return false; } }, - { message: "Umami Script-URL ist ungültig" }, + { message: "Umami script URL is invalid" }, ) .optional(), umamiWebsiteId: z.string().optional(), @@ -54,7 +54,7 @@ export const adminSettingsSchema = z return false; } }, - { message: "Glitchtip DSN ist ungültig" }, + { message: "Glitchtip DSN is invalid" }, ) .optional(), glitchtipEnvironment: z.string().optional(), @@ -66,7 +66,7 @@ export const adminSettingsSchema = z if (!trimmed) return true; return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(trimmed); }, - { message: "GitHub-Repository muss im Format owner/repo sein" }, + { message: "GitHub repository must be in owner/repo format" }, ) .optional(), bugReportToken: z.string().optional(), diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index f6d6b0978..4dcdf4523 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -66,7 +66,7 @@ export function validateMeasurementRange( ): string | null { const range = VALUE_RANGES[type]; if (range && (value < range.min || value > range.max)) { - return `Wert muss zwischen ${range.min} und ${range.max} liegen`; + return `Value must be between ${range.min} and ${range.max}`; } return null; } @@ -104,7 +104,7 @@ export const updateMeasurementSchema = z.object({ .optional(), notes: z .string() - .max(25, "Kommentar darf maximal 25 Zeichen haben") + .max(25, "Note cannot exceed 25 characters") .nullable() .optional(), });