import { closeSync, existsSync, mkdirSync, openSync, statSync, unlinkSync } from "node:fs"; import { resolve } from "node:path"; import { and, eq } from "drizzle-orm"; import { db } from "../db/client.js"; import { getDataDir } from "../db/path-utils.js"; import { doseTracking, medications } from "../db/schema.js"; import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js"; import { getAllUserSettings, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, normalizePackageType, } from "../utils/package-profiles.js"; // Import shared utilities import { type Blister, calculateDepletionInfo, countScheduledOccurrencesInRange, formatInTimezone, getCurrentHourInTimezone, getDateOnlyTimestamp, getEffectiveTimezone, getMsUntilNextCheck, getNextScheduledOccurrenceTime, getNextScheduledTime, getTimezone, getTodayInTimezone, normalizeIntakeUsageForStock, parseIntakesJson, parseLocalDateTime, parseTakenByJson, } from "../utils/scheduler-utils.js"; import { buildPrescriptionReminderPushNotification, buildStockReminderPushNotification, } from "./notifications/builders.js"; import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js"; import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js"; import { formatPlannerQuantity } from "./planner-service.js"; export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.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 reminderLocksDir = resolve(getDataDir(), "scheduler-locks"); const LOCK_STALE_MS = 15 * 60 * 1000; function ensureReminderLocksDir(): void { if (!existsSync(reminderLocksDir)) { mkdirSync(reminderLocksDir, { recursive: true }); } } function acquireReminderSendLock(lockKey: string): string | null { ensureReminderLocksDir(); const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`); const tryCreateLock = (): boolean => { try { const fd = openSync(lockFilePath, "wx"); closeSync(fd); return true; } catch { return false; } }; if (tryCreateLock()) { return lockFilePath; } try { const stats = statSync(lockFilePath); if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) { unlinkSync(lockFilePath); if (tryCreateLock()) { return lockFilePath; } } } catch { // ignore; lock acquisition fails safely } return null; } function releaseReminderSendLock(lockFilePath: string | null): void { if (!lockFilePath) return; try { unlinkSync(lockFilePath); } catch { // ignore release errors } } type LowStockItem = { name: string; medsLeft: number; packageType?: string; daysLeft: number | null; depletionDate: string | null; isCritical: boolean; }; function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } { const lowDays = Math.max(1, Math.floor(baselineDays)); const criticalDays = Math.max(1, Math.ceil(lowDays / 2)); return { lowDays, criticalDays }; } type PrescriptionReminderItem = { name: string; remainingRefills: number; lowThreshold: number; expiryDate: string | null; }; function getMedicationDisplayName(row: { id: number; name: string | null; genericName: string | null }): string { const commercialName = row.name?.trim() ?? ""; if (commercialName) return commercialName; const genericName = row.genericName?.trim() ?? ""; if (genericName) return genericName; return `Medication #${row.id}`; } 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); let takenAtMs: number; if (Number.isFinite(rawTakenAt)) { takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt; } else { takenAtMs = new Date(dose.takenAt).getTime(); } takenDoseTimestamps.set(dose.doseId, takenAtMs); } const lowStock: LowStockItem[] = []; const now = Date.now(); for (const row of rows) { const packageType = normalizePackageType(row.packageType); // Tube stock reminders are intentionally disabled: // topical usage in grams cannot be mapped reliably to schedule events. if (isTubePackageType(packageType)) continue; const intakes = parseIntakesJson( row.intakesJson, { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson }, row.intakeRemindersEnabled ?? false ); const blisters: Blister[] = intakes.map((i) => ({ usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType), every: i.every, start: i.start, scheduleMode: i.scheduleMode, weekdays: i.weekdays, })); const originalTotalPills = isAmountBasedPackageType(packageType) ? 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 effectiveStart = stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart ? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false) : blisterStart; if (effectiveStart === null) return; const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; const fallbackPeople = parseTakenByJson(row.takenByJson); let peopleForThisIntake: Array; if (intakePerson) { peopleForThisIntake = [intakePerson]; } else if (fallbackPeople.length > 0) { peopleForThisIntake = fallbackPeople; } else { peopleForThisIntake = [null]; } let timeBasedConsumed = 0; let lastAutoConsumedDateMs = 0; if (effectiveStart <= now) { const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange( blister, effectiveStart, now ); timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; if (lastOccurrenceMs !== null) { lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs)); } } const stockCorrectionDateOnly = stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 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 isLiquid = isLiquidContainerPackageType(packageType); const { lowDays, criticalDays } = isLiquid ? getLiquidReminderThresholds(reminderDaysBefore) : { lowDays: lowStockDays, criticalDays: reminderDaysBefore }; const isCritical = daysLeft <= criticalDays; const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays; if (isCritical || isLow) { lowStock.push({ name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }), medsLeft: currentPills, packageType, 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: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }), 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 smtp = getSmtpConfig(); if (!smtp.host || !smtp.user) { 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; const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr); return ` ${statusIcon} ${row.name} ${quantityText} ${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}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${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 }); const emailResult = await sendEmailNotification({ to: email, subject, text: plainText, html, from: smtp.from, }); if (!emailResult.success) { return { success: false, error: emailResult.error ?? "Unknown error" }; } return { success: true }; } async function checkAndSendReminder(logger: ServiceLogger): Promise { // Track stock-scheduler daily execution separately from intake updates. // This prevents intake reminders from suppressing stock catch-up after restarts. const state = loadReminderState(); const today = getTodayInTimezone(); saveReminderState({ ...state, lastStockSchedulerCheckDate: today, }); // 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 userTimezone = getEffectiveTimezone(settings.timezone ?? null); const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user 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) { const stockSendLock = acquireReminderSendLock(userStockNotifiedKey); if (!stockSendLock) { logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send"); } else { try { logger.info(`[Reminder] 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] Failed to send stock email: ${result.error}`); } } if (stockPushEnabled) { const pushPayload = buildStockReminderPushNotification(allLowStock, language); const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message); shoutrrrSuccess = result.success; if (!result.success) { logger.error(`[Reminder] 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, lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate, 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); } } finally { releaseReminderSendLock(stockSendLock); } } } } if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) { if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) { const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey); if (!prescriptionSendLock) { logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send"); } else { try { // Re-check using fresh state after acquiring lock and pre-mark today as notified. // This blocks duplicate sends when two reminder checks overlap in time. const lockedState = loadReminderState(); const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey); const shouldSend = !alreadyNotified || settings.repeatDailyReminders; if (!shouldSend) { logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping"); } const preMarkedNotified = !shouldSend || alreadyNotified ? lockedState.notifiedMedications : [...new Set([...lockedState.notifiedMedications, userPrescriptionNotifiedKey])]; if (shouldSend && !alreadyNotified) { saveReminderState({ lastAutoEmailSent: lockedState.lastAutoEmailSent, lastAutoEmailDate: lockedState.lastAutoEmailDate, lastStockSchedulerCheckDate: lockedState.lastStockSchedulerCheckDate, notifiedMedications: preMarkedNotified, nextScheduledCheck: lockedState.nextScheduledCheck, lastNotificationType: lockedState.lastNotificationType, lastNotificationChannel: lockedState.lastNotificationChannel, }); } if (shouldSend) { logger.info(`[Reminder] 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 smtp = getSmtpConfig(); if (smtp.host && smtp.user) { try { 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}` : ""}`; const mailResult = await sendEmailNotification({ to: settings.notificationEmail!, subject, text, html, from: smtp.from, }); if (!mailResult.success) { throw new Error(mailResult.error ?? "Unknown error"); } emailSuccess = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`); } } } if (prescriptionPushEnabled) { const pushPayload = buildPrescriptionReminderPushNotification(allPrescriptionLow, language); const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message); shoutrrrSuccess = result.success; if (!result.success) { logger.error(`[Reminder] 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, lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate, 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); } else if (!alreadyNotified) { // Roll back pre-mark when both channels failed so retries remain possible. const currentState = loadReminderState(); saveReminderState({ lastAutoEmailSent: currentState.lastAutoEmailSent, lastAutoEmailDate: currentState.lastAutoEmailDate, lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate, notifiedMedications: currentState.notifiedMedications.filter( (key) => key !== userPrescriptionNotifiedKey ), nextScheduledCheck: currentState.nextScheduledCheck, lastNotificationType: currentState.lastNotificationType, lastNotificationChannel: currentState.lastNotificationChannel, }); } } } finally { releaseReminderSendLock(prescriptionSendLock); } } } } } let schedulerTimeout: NodeJS.Timeout | null = null; let schedulerStarted = false; 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 { if (schedulerStarted) { logger.info(`[Reminder] Scheduler already started, skipping duplicate start call`); return; } schedulerStarted = true; 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 one catch-up. // This is intentionally a single current-state snapshot (no replay of missed days). if (currentHour >= REMINDER_HOUR && state.lastStockSchedulerCheckDate !== today) { logger.info("[Reminder] Missed today's check, running one catch-up snapshot (no historical replay)..."); 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 async function runReminderSchedulerNow(logger: ServiceLogger): Promise { logger.info(`[Reminder] Manual trigger: running reminder check now (${getTimezone()})`); await checkAndSendReminder(logger); } export function stopReminderScheduler(): void { if (schedulerTimeout) { clearTimeout(schedulerTimeout); schedulerTimeout = null; } schedulerStarted = false; }