import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, 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 { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, normalizePackageType, } from "../utils/package-profiles.js"; // Import shared utilities import { type Blister, calculateDepletionInfo, countScheduledOccurrencesInRange, createDefaultReminderState, formatInTimezone, getCurrentHourInTimezone, getDateOnlyTimestamp, getMsUntilNextCheck, getNextScheduledOccurrenceTime, getNextScheduledTime, getTimezone, getTodayInTimezone, normalizeIntakeUsageForStock, 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); } type MailDeliveryInfo = { accepted?: unknown; rejected?: unknown; response?: unknown; }; function normalizeRecipients(value: unknown): string[] { if (!Array.isArray(value)) return []; return value .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) .map((entry) => entry.trim()) .filter(Boolean); } function getDeliveryError(info: MailDeliveryInfo): string | null { const accepted = normalizeRecipients(info.accepted); const rejected = normalizeRecipients(info.rejected); if (accepted.length > 0) return null; if (rejected.length > 0) { return `SMTP rejected all recipients: ${rejected.join(", ")}`; } if (typeof info.response === "string" && info.response.trim()) { return `SMTP did not confirm accepted recipients. Response: ${info.response}`; } return "SMTP did not confirm accepted recipients."; } const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); 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 } } 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; }; 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; }; 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: 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 ?? "", }, }); const mailResult = await transporter.sendMail({ from: smtpFrom, to: email, subject, text: plainText, html, }); const deliveryError = getDeliveryError(mailResult); if (deliveryError) { throw new Error(deliveryError); } 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 { // 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 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) { 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 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] 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 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}` : ""}`; const mailResult = await transporter.sendMail({ from: smtpFrom, to: settings.notificationEmail!, subject, text, html, }); const deliveryError = getDeliveryError(mailResult); if (deliveryError) { throw new Error(deliveryError); } 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 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] 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; }