import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { and, eq } from "drizzle-orm"; import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { getDataDir } from "../db/db-utils.js"; import { doseTracking, medications, userSettings } from "../db/schema.js"; import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; // Import shared utilities import { type Blister, calculateDepletionInfo, createDefaultReminderState, formatInTimezone, getCurrentHourInTimezone, getMsUntilNextCheck, getNextScheduledTime, getTimezone, getTodayInTimezone, parseIntakesJson, parseLocalDateTime, parseReminderState, parseTakenByJson, type ReminderState, } from "../utils/scheduler-utils.js"; function escapeHtml(text: string): string { const htmlEscapes: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char); } const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); function loadReminderState(): ReminderState { try { if (existsSync(reminderStateFile)) { return parseReminderState(readFileSync(reminderStateFile, "utf-8")); } } catch { // ignore } return createDefaultReminderState(); } function saveReminderState(state: ReminderState): void { writeFileSync(reminderStateFile, JSON.stringify(state, null, 2)); } export function getReminderState(): ReminderState { return loadReminderState(); } export function updateReminderSentTime( type: "stock" | "intake" | "prescription" = "stock", channel: "email" | "push" | "both" = "email" ): void { const state = loadReminderState(); const today = getTodayInTimezone(); saveReminderState({ ...state, lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, lastNotificationType: type, lastNotificationChannel: channel, }); } // Update user settings in database when reminder is sent // Stock and intake reminders are tracked separately so neither overwrites the other export async function updateUserReminderSentTime( userId: number, type: "stock" | "intake" | "prescription" = "stock", channel: "email" | "push" | "both" = "email", medName?: string, takenBy?: string ): Promise { const now = new Date().toISOString(); if (type === "stock") { // Write to dedicated stock reminder columns only — do NOT touch the shared // lastNotificationType column, as that would block intake reminder display await db .update(userSettings) .set({ lastStockReminderSent: now, lastStockReminderChannel: channel, lastStockReminderMedNames: medName ?? null, }) .where(eq(userSettings.userId, userId)); } else if (type === "prescription") { // Write to dedicated prescription reminder columns only await db .update(userSettings) .set({ lastPrescriptionReminderSent: now, lastPrescriptionReminderChannel: channel, lastPrescriptionReminderMedNames: medName ?? null, }) .where(eq(userSettings.userId, userId)); } else { // Write to intake reminder columns await db .update(userSettings) .set({ lastAutoEmailSent: now, lastNotificationType: type, lastNotificationChannel: channel, lastReminderMedName: medName ?? null, lastReminderTakenBy: takenBy ?? null, }) .where(eq(userSettings.userId, userId)); } } type LowStockItem = { name: string; medsLeft: number; daysLeft: number | null; depletionDate: string | null; isCritical: boolean; }; type PrescriptionReminderItem = { name: string; remainingRefills: number; lowThreshold: number; expiryDate: string | null; }; async function getMedicationsNeedingReminder( userId: number, reminderDaysBefore: number, lowStockDays: number, language: Language, stockCalculationMode: "automatic" | "manual" ): Promise { const rows = await db .select() .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) .orderBy(medications.id); const takenDoseRows = await db .select() .from(doseTracking) .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false))); const takenDoseIdsByMed = new Map>(); const takenDoseTimestamps = new Map(); for (const dose of takenDoseRows) { const parts = dose.doseId.split("-"); if (parts.length < 3) continue; const medId = parseInt(parts[0], 10); if (Number.isNaN(medId)) continue; if (!takenDoseIdsByMed.has(medId)) { takenDoseIdsByMed.set(medId, new Set()); } takenDoseIdsByMed.get(medId)!.add(dose.doseId); const rawTakenAt = Number(dose.takenAt); const takenAtMs = Number.isFinite(rawTakenAt) ? rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt : new Date(dose.takenAt).getTime(); takenDoseTimestamps.set(dose.doseId, takenAtMs); } const lowStock: LowStockItem[] = []; const now = Date.now(); const msPerDay = 86_400_000; for (const row of rows) { const intakes = parseIntakesJson( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false ); const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); const originalTotalPills = (row.packageType ?? "blister") === "bottle" ? row.looseTablets + (row.stockAdjustment ?? 0) : row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0); const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0; const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set(); let consumed = 0; if (stockCalculationMode === "automatic") { blisters.forEach((blister, blisterIdx) => { const blisterStart = parseLocalDateTime(blister.start).getTime(); if (Number.isNaN(blisterStart)) return; const period = Math.max(1, blister.every) * msPerDay; let effectiveStart: number; if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) { const elapsedSinceStart = stockCorrectionCutoff - blisterStart; const periodsElapsed = Math.floor(elapsedSinceStart / period); effectiveStart = blisterStart + (periodsElapsed + 1) * period; } else { effectiveStart = blisterStart; } const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; const fallbackPeople = parseTakenByJson(row.takenByJson); const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [null]; let timeBasedConsumed = 0; let lastAutoConsumedDateMs = 0; if (effectiveStart <= now) { const occurrences = Math.floor((now - effectiveStart) / period) + 1; timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); lastAutoConsumedDateMs = new Date( lastDoseTime.getFullYear(), lastDoseTime.getMonth(), lastDoseTime.getDate() ).getTime(); } const stockCorrectionDateOnly = stockCorrectionCutoff > 0 ? new Date( new Date(stockCorrectionCutoff).getFullYear(), new Date(stockCorrectionCutoff).getMonth(), new Date(stockCorrectionCutoff).getDate() ).getTime() : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); let earlyTakenConsumed = 0; for (const doseId of takenDoseIds) { const parts = doseId.split("-"); if (parts.length < 3) continue; const bIdx = parseInt(parts[1], 10); const timestamp = parseInt(parts[2], 10); if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) { earlyTakenConsumed += blister.usage; } } consumed += timeBasedConsumed + earlyTakenConsumed; }); } else { blisters.forEach((blister, blisterIdx) => { const blisterStart = parseLocalDateTime(blister.start); const blisterStartDateOnly = new Date( blisterStart.getFullYear(), blisterStart.getMonth(), blisterStart.getDate() ).getTime(); if (Number.isNaN(blisterStartDateOnly)) return; for (const doseId of takenDoseIds) { const parts = doseId.split("-"); if (parts.length < 3) continue; const parsedBlisterIdx = parseInt(parts[1], 10); const doseTimestamp = parseInt(parts[2], 10); if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) { continue; } const takenAt = takenDoseTimestamps.get(doseId) ?? 0; const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) { consumed += blister.usage; } } }); } const currentPills = Math.max(0, originalTotalPills - consumed); const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language); if (daysLeft === null) continue; const isCritical = daysLeft <= reminderDaysBefore; const isLow = daysLeft < lowStockDays; if (isCritical || isLow) { lowStock.push({ name: row.name, medsLeft: currentPills, daysLeft, depletionDate, isCritical, }); } } return lowStock; } async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise { const rows = await db .select() .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) .orderBy(medications.id); return rows .filter( (row) => (row.prescriptionEnabled ?? false) && (row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1) ) .map((row) => ({ name: row.name, remainingRefills: row.prescriptionRemainingRefills ?? 0, lowThreshold: row.prescriptionLowRefillThreshold ?? 1, expiryDate: row.prescriptionExpiryDate ?? null, })); } // Test-only hook to validate scheduler stock semantics against planner/coverage behavior. export async function getMedicationsNeedingReminderForTests( userId: number, reminderDaysBefore: number, lowStockDays: number, language: Language, stockCalculationMode: "automatic" | "manual" ): Promise< Array<{ name: string; medsLeft: number; daysLeft: number | null; depletionDate: string | null; isCritical: boolean; }> > { return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode); } async function sendReminderEmail( email: string, lowStock: LowStockItem[], language: Language, isRepeatDaily: boolean = false ): Promise<{ success: boolean; error?: string }> { const smtpHost = process.env.SMTP_HOST; const smtpUser = process.env.SMTP_USER; const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; if (!smtpHost || !smtpUser) { return { success: false, error: "SMTP not configured" }; } const tr = getTranslations(language); // Separate into 3 categories: empty, critical, and low stock const emptyMeds = lowStock.filter((item) => item.medsLeft <= 0); const criticalMeds = lowStock.filter((item) => item.medsLeft > 0 && item.isCritical); const lowStockMeds = lowStock.filter((item) => item.medsLeft > 0 && !item.isCritical); // Build per-category alert boxes const alertParts: string[] = []; if (emptyMeds.length > 0) { const emptyAlert = emptyMeds.length === 1 ? tr.stockReminder.alertEmptySingle : t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length }); alertParts.push(`

${emptyAlert}

`); } if (criticalMeds.length > 0) { const criticalAlert = criticalMeds.length === 1 ? tr.stockReminder.alertLowSingle : t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length }); alertParts.push(`

${criticalAlert}

`); } if (lowStockMeds.length > 0) { const lowAlert = lowStockMeds.length === 1 ? tr.stockReminder.alertLowStockSingle : t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length }); alertParts.push(`

${lowAlert}

`); } const alertHtml = alertParts.join(""); // Build description text let descriptionText: string; if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) { descriptionText = tr.stockReminder.descriptionMixed; } else if (emptyMeds.length > 0) { descriptionText = tr.stockReminder.descriptionEmpty; } else if (criticalMeds.length > 0) { descriptionText = tr.stockReminder.description; } else { descriptionText = tr.stockReminder.descriptionLow; } // Build table rows with status indicator const tableRows = lowStock .map((row) => { const isEmpty = row.medsLeft <= 0; const isCritical = row.isCritical; const nonEmptyIcon = isCritical ? "🚨" : "⚠️"; const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; const nonEmptyBg = isCritical ? "#fff7ed" : "white"; const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; return ` ${statusIcon} ${row.name} ${row.medsLeft} ${row.daysLeft ?? 0} ${isEmpty ? `${tr.stockReminder.now ?? "-"}` : (row.depletionDate ?? "-")} `; }) .join(""); const html = `

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - ${tr.push.reorderNow}

${descriptionText}

${alertHtml}
${tableRows}
${tr.stockReminder.tableHeaders.medication} ${tr.stockReminder.tableHeaders.pills} ${tr.stockReminder.tableHeaders.days} ${tr.stockReminder.tableHeaders.runsOut}

${getFooterHtml(language)}

${isRepeatDaily ? `

${tr.stockReminder.repeatDailyNote}

` : ""}
`; const plainText = `${tr.stockReminder.title} ${tr.stockReminder.description} ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")} --- ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; const pluralSuffix = language === "de" ? "e" : "s"; const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix; const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural }); try { const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpSecure, auth: { user: smtpUser, pass: smtpPass ?? "", }, }); await transporter.sendMail({ from: smtpFrom, to: email, subject, text: plainText, html, }); return { success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; return { success: false, error: errorMessage }; } } async function checkAndSendReminder(logger: ServiceLogger): Promise { // Get all user settings to iterate over each user const allUserSettings = await getAllUserSettings(); if (allUserSettings.length === 0) { logger.debug("[Reminder] No users with settings found"); return; } for (const userSettings of allUserSettings) { await checkAndSendReminderForUser(userSettings, logger); } } async function checkAndSendReminderForUser( settings: UserSettings & { userId: number }, logger: ServiceLogger ): Promise { const language = settings.language; const tr = getTranslations(language); const stockEmailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders; const stockPushEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders; const prescriptionEmailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailPrescriptionReminders; const prescriptionPushEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrPrescriptionReminders; if (!stockEmailEnabled && !stockPushEnabled && !prescriptionEmailEnabled && !prescriptionPushEnabled) { return; } const state = loadReminderState(); const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone const userStateKey = `user_${settings.userId}`; const userStockNotifiedKey = `${userStateKey}_${today}_stock`; const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`; const allLowStock = await getMedicationsNeedingReminder( settings.userId, settings.reminderDaysBefore, settings.lowStockDays, language, settings.stockCalculationMode ); const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId); if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) { if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) { logger.info( `[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...` ); let emailSuccess = false; let shoutrrrSuccess = false; if (stockEmailEnabled) { const result = await sendReminderEmail( settings.notificationEmail!, allLowStock, language, settings.repeatDailyReminders ); emailSuccess = result.success; if (!result.success) { logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`); } } if (stockPushEnabled) { const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0); const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical); const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical); const titleParts: string[] = []; if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`); if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; const messageParts: string[] = []; if (emptyMeds.length > 0) { messageParts.push(`🚨 ${tr.push.emptySection}:`); emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`)); } if (criticalMeds.length > 0) { if (messageParts.length > 0) messageParts.push(""); messageParts.push(`🚨 ${tr.push.criticalSection}:`); criticalMeds.forEach((m) => messageParts.push( ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` ) ); } if (lowStockMeds.length > 0) { if (messageParts.length > 0) messageParts.push(""); messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); lowStockMeds.forEach((m) => messageParts.push( ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` ) ); } const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`); } } if (emailSuccess || shoutrrrSuccess) { const currentState = loadReminderState(); const singleChannel = emailSuccess ? "email" : "push"; const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel; saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])], nextScheduledCheck: currentState.nextScheduledCheck, lastNotificationType: "stock", lastNotificationChannel: channel, }); const medNames = allLowStock.map((m) => m.name).join(", "); await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); } } } if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) { if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) { logger.info( `[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...` ); const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0); const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0); const lines = allPrescriptionLow.map((m) => { const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : ""; if (m.remainingRefills <= 0) { return `- ${t(tr.prescriptionReminder.lineEmpty, { name: m.name, expirySuffix, })}`; } return `- ${t(tr.prescriptionReminder.line, { name: m.name, refills: m.remainingRefills, expirySuffix, })}`; }); let emailSuccess = false; let shoutrrrSuccess = false; if (prescriptionEmailEnabled) { const smtpHost = process.env.SMTP_HOST; const smtpUser = process.env.SMTP_USER; const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; if (smtpHost && smtpUser) { try { const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpSecure, auth: { user: smtpUser, pass: smtpPass ?? "" }, }); const subject = allPrescriptionLow.length === 1 ? tr.prescriptionReminder.subjectSingle : t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length }); const bodyText = emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; const emptyAlert = emptyRx.length === 1 ? tr.prescriptionReminder.alertEmptySingle : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }); const lowAlert = lowRx.length === 1 ? tr.prescriptionReminder.alertLowSingle : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert; const tableRows = allPrescriptionLow .map((item) => { const isEmpty = item.remainingRefills <= 0; const safeName = escapeHtml(item.name); const safeRefills = Number(item.remainingRefills) || 0; const safeThreshold = Number(item.lowThreshold) || 0; const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-"; const rowBg = isEmpty ? "#fef2f2" : "white"; return ` ${isEmpty ? "🚨" : "⚠️"} ${safeName} ${safeRefills} ${safeThreshold} ${safeExpiry} `; }) .join(""); const html = `

${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}

${bodyText}

${alertText}

${tableRows}
${tr.prescriptionReminder.tableHeaders.medication} ${tr.prescriptionReminder.tableHeaders.refillsLeft} ${tr.prescriptionReminder.tableHeaders.reminderThreshold} ${tr.prescriptionReminder.tableHeaders.prescriptionExpires}

${getFooterHtml(language)}

${settings.repeatDailyReminders ? `

${tr.prescriptionReminder.repeatDailyNote}

` : ""}
`; const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`; await transporter.sendMail({ from: smtpFrom, to: settings.notificationEmail!, subject, text, html, }); emailSuccess = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`); } } } if (prescriptionPushEnabled) { const titleParts: string[] = []; if (emptyRx.length > 0) titleParts.push( `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` ); if (lowRx.length > 0) titleParts.push( `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` ); const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; const messageParts: string[] = []; if (emptyRx.length > 0) { messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); for (const m of emptyRx) { messageParts.push(` • ${m.name}`); } } if (lowRx.length > 0) { if (emptyRx.length > 0) messageParts.push(""); messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); for (const m of lowRx) { messageParts.push( ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` ); } } const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`); } } if (emailSuccess || shoutrrrSuccess) { const currentState = loadReminderState(); const singleChannel = emailSuccess ? "email" : "push"; const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel; saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])], nextScheduledCheck: currentState.nextScheduledCheck, lastNotificationType: "prescription", lastNotificationChannel: channel, }); const medNames = allPrescriptionLow.map((m) => m.name).join(", "); await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames); } } } } let schedulerTimeout: NodeJS.Timeout | null = null; function scheduleNextCheck(logger: ServiceLogger): void { const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR); const nextTime = getNextScheduledTime(REMINDER_HOUR); // Save next scheduled time to state const state = loadReminderState(); saveReminderState({ ...state, nextScheduledCheck: nextTime.toISOString(), }); logger.debug( `[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)` ); schedulerTimeout = setTimeout(() => { checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`)); // Schedule the next check after this one completes scheduleNextCheck(logger); }, msUntilNext); } export function startReminderScheduler(logger: ServiceLogger): void { logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`); // Check if we need to run immediately (missed today's check) const state = loadReminderState(); const today = getTodayInTimezone(); const currentHour = getCurrentHourInTimezone(); // If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) { logger.info("[Reminder] Missed today's check, running now..."); checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`)); } // Schedule next check at REMINDER_HOUR scheduleNextCheck(logger); logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`); } export function stopReminderScheduler(): void { if (schedulerTimeout) { clearTimeout(schedulerTimeout); schedulerTimeout = null; } }