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(),
});