diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index c342ffc..e38e1f1 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +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"; @@ -40,6 +40,56 @@ function escapeHtml(text: string): string { 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 { @@ -565,166 +615,184 @@ async function checkAndSendReminderForUser( 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 })}` - ) + const stockSendLock = acquireReminderSendLock(userStockNotifiedKey); + if (!stockSendLock) { + logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`); + } else { + try { + logger.info( + `[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...` ); - } - 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, - }); + let emailSuccess = false; + let shoutrrrSuccess = false; - const medNames = allLowStock.map((m) => m.name).join(", "); - await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); + 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); + } + } finally { + releaseReminderSendLock(stockSendLock); + } } } } 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 prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey); + if (!prescriptionSendLock) { + logger.debug( + `[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send` + ); + } else { + try { + 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, - })}`; - }); + 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; + 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 (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 ?? "" }, - }); + 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 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 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 ` + 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 `