import nodemailer from "nodemailer"; import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; import { medications, userSettings } from "../db/schema.js"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve } from "path"; import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import { getTranslations, t, type Language } from "../i18n/translations.js"; // Import shared utilities import { getTimezone, formatInTimezone, getCurrentHourInTimezone, getTodayInTimezone, getNextScheduledTime, getMsUntilNextCheck, parseBlisters, calculateDailyUsage, calculateDepletionInfo, parseReminderState, createDefaultReminderState, type Blister, type ReminderState, } from "../utils/scheduler-utils.js"; const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const reminderStateFile = resolve(process.cwd(), "data", "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" = "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 export async function updateUserReminderSentTime( userId: number, type: "stock" | "intake" = "stock", channel: "email" | "push" | "both" = "email" ): Promise { const now = new Date().toISOString(); await db.update(userSettings) .set({ lastAutoEmailSent: now, lastNotificationType: type, lastNotificationChannel: channel, }) .where(eq(userSettings.userId, userId)); } function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { return parseBlisters(row); } type LowStockItem = { name: string; medsLeft: number; daysLeft: number | null; depletionDate: string | null; }; async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise { const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); const lowStock: LowStockItem[] = []; for (const row of rows) { const blisters = parseBlistersFromRow(row); const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0); const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language); // Check if medication runs out within reminderDaysBefore days if (daysLeft !== null && daysLeft <= reminderDaysBefore) { lowStock.push({ name: row.name, medsLeft: totalPills, daysLeft, depletionDate, }); } } return lowStock; } 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"); 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); const tableRows = lowStock .map( (row) => ` ${row.name} ${row.medsLeft} ${row.daysLeft ?? 0} ${row.depletionDate ?? "-"} ` ) .join(""); const alertText = lowStock.length === 1 ? tr.stockReminder.alertSingle : t(tr.stockReminder.alertMultiple, { count: lowStock.length }); const html = `

${tr.stockReminder.title}

${tr.stockReminder.description}

${alertText}

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

${tr.stockReminder.footer}

${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")} --- ${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; const subjectPlural = lowStock.length === 1 ? "" : (language === "de" ? "e" : "s"); 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: `⚠️ ${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: { info: (msg: string) => void; error: (msg: string) => void }): Promise { // Get all user settings to iterate over each user const allUserSettings = await getAllUserSettings(); if (allUserSettings.length === 0) { logger.info("[Reminder] No users with settings found"); return; } for (const userSettings of allUserSettings) { await checkAndSendReminderForUser(userSettings, logger); } } async function checkAndSendReminderForUser( settings: UserSettings & { userId: number }, logger: { info: (msg: string) => void; error: (msg: string) => void } ): Promise { const language = settings.language; const tr = getTranslations(language); // Check if any stock reminder notifications are enabled (granular check) const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders; const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders; if (!emailEnabled && !shoutrrrEnabled) { return; // No stock reminder notifications enabled for this user } const state = loadReminderState(); const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone const userStateKey = `user_${settings.userId}`; // Get all medications that need a reminder for this user const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language); if (allLowStock.length === 0) { return; // No low stock for this user } // Simple per-user tracking - check if we already sent today const userNotifiedKey = `${userStateKey}_${today}`; if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) { return; // Already notified this user today } logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`); let emailSuccess = false; let shoutrrrSuccess = false; // Send email if enabled if (emailEnabled) { const result = await sendReminderEmail(settings.notificationEmail!, allLowStock, language, settings.repeatDailyReminders); emailSuccess = result.success; if (result.success) { logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`); } else { logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`); } } // Send Shoutrrr notification if enabled if (shoutrrrEnabled) { // Separate empty from low stock medications const emptyMeds = allLowStock.filter(m => m.medsLeft <= 0); const lowMeds = allLowStock.filter(m => m.medsLeft > 0); // Build clear title const titleParts: string[] = []; if (emptyMeds.length > 0) { titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`); } if (lowMeds.length > 0) { titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`); } const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`; // Build clear message with sections const messageParts: string[] = []; if (emptyMeds.length > 0) { messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`); emptyMeds.forEach(m => messageParts.push(` • ${m.name}`)); } if (lowMeds.length > 0) { if (emptyMeds.length > 0) messageParts.push(""); messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`); lowMeds.forEach(m => messageParts.push(` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`)); } if (settings.repeatDailyReminders) { messageParts.push(""); messageParts.push(tr.push.repeatDailyNote); } const message = messageParts.join("\n"); const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (result.success) { logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`); } else { logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`); } } // Update state if any notification was sent successfully if (emailSuccess || shoutrrrSuccess) { const currentState = loadReminderState(); const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])], nextScheduledCheck: currentState.nextScheduledCheck, lastNotificationType: "stock", lastNotificationChannel: channel, }); // Also update user settings in database so frontend can display the info await updateUserReminderSentTime(settings.userId, "stock", channel); } } let schedulerTimeout: NodeJS.Timeout | null = null; function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): 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.info(`[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: { info: (msg: string) => void; error: (msg: string) => void }): 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; } }