import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { medications } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js";
type Slice = { usage: number; every: number; start: string };
type IntakeReminderState = {
sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders
};
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
// Get current timezone from TZ env variable or default to UTC
function getTimezone(): string {
return process.env.TZ || "UTC";
}
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
function loadIntakeReminderState(): IntakeReminderState {
try {
if (existsSync(intakeReminderStateFile)) {
const saved = JSON.parse(readFileSync(intakeReminderStateFile, "utf-8"));
return {
sentReminders: saved.sentReminders ?? [],
};
}
} catch {
// ignore
}
return { sentReminders: [] };
}
function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
function parseSlices(row: { usageJson: string; everyJson: string; startJson: string }): Slice[] {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
const len = Math.min(usage.length, every.length, start.length);
const slices: Slice[] = [];
for (let i = 0; i < len; i++) {
slices.push({ usage: usage[i], every: every[i], start: start[i] });
}
return slices;
} catch {
return [];
}
}
type UpcomingIntake = {
medName: string;
usage: number;
intakeTime: Date;
intakeTimeStr: string;
takenBy: string | null;
pillWeightMg: number | null;
};
function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: number, takenBy: string | null, pillWeightMg: number | null, locale: string): UpcomingIntake[] {
const now = Date.now();
// Window to detect if "now" is the right time to send reminder
// We check if the notify time (intake - 15min) falls within current minute ±1
const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks)
const windowEnd = now + 1 * 60 * 1000; // 1 minute from now
const upcoming: UpcomingIntake[] = [];
for (const slice of slices) {
const startTime = new Date(slice.start).getTime();
const intervalMs = slice.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Find the next scheduled intake time (could be today or in the future)
let nextTime = startTime;
// If start is in the past, calculate occurrences
if (nextTime < now) {
const elapsed = now - startTime;
const intervals = Math.floor(elapsed / intervalMs);
// Check the current occurrence (today's scheduled time, even if past)
const currentOccurrence = startTime + intervals * intervalMs;
// And the next occurrence
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
// If today's occurrence is within the reminder window, use it
// (intake hasn't happened yet, we should remind)
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= windowStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
}
}
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
const intakeDate = new Date(nextTime);
upcoming.push({
medName,
usage: slice.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: getTimezone()
}),
takenBy,
pillWeightMg,
});
}
}
return upcoming;
}
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
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);
// Helper to format dosage with weight
const formatDosage = (intake: UpcomingIntake): string => {
const pillText = `${intake.usage} ${intake.usage === 1 ? tr.common.pill : tr.intakeReminder.pills}`;
if (intake.pillWeightMg) {
const totalMg = intake.usage * intake.pillWeightMg;
const weightStr = totalMg >= 1000 ? `${(totalMg / 1000).toFixed(1)} g` : `${totalMg} mg`;
return `${pillText} (${weightStr})`;
}
return pillText;
};
// Helper to format medication name with takenBy
const formatMedName = (intake: UpcomingIntake): string => {
if (intake.takenBy) {
return `${intake.medName} ${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}`;
}
return intake.medName;
};
const tableRows = intakes
.map(
(intake) => `
| ${formatMedName(intake)} |
${formatDosage(intake)} |
${intake.intakeTimeStr} |
`
)
.join("");
const alertText = intakes.length === 1
? tr.intakeReminder.alertSingle
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
const html = `
${tr.intakeReminder.title}
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
| ${tr.intakeReminder.tableHeaders.medication} |
${tr.intakeReminder.tableHeaders.dosage} |
${tr.intakeReminder.tableHeaders.time} |
${tableRows}
${tr.intakeReminder.footer}
`;
// Helper for plain text dosage
const formatDosagePlain = (intake: UpcomingIntake): string => {
const pillText = `${intake.usage} ${intake.usage === 1 ? tr.common.pill : tr.intakeReminder.pills}`;
if (intake.pillWeightMg) {
const totalMg = intake.usage * intake.pillWeightMg;
const weightStr = totalMg >= 1000 ? `${(totalMg / 1000).toFixed(1)} g` : `${totalMg} mg`;
return `${pillText} (${weightStr})`;
}
return pillText;
};
const plainText = `${tr.intakeReminder.title}
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
${intakes.map((i) => {
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
}).join("\n")}
---
${tr.intakeReminder.footer}`;
const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
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 checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise {
const settings = loadNotificationSettings();
const language = settings.language;
const tr = getTranslations(language);
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) {
return; // No intake reminder notifications enabled, skip silently
}
// Get all medications with intake reminders enabled
const rows = await db.select().from(medications).orderBy(medications.id);
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
return; // No medications have reminders enabled
}
const state = loadIntakeReminderState();
const allUpcoming: UpcomingIntake[] = [];
const locale = getDateLocale(language);
// Find all upcoming intakes across all medications
for (const med of medsWithReminders) {
const slices = parseSlices(med);
const upcoming = getUpcomingIntakes(med.name, slices, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale);
allUpcoming.push(...upcoming);
}
if (allUpcoming.length === 0) {
return; // No upcoming intakes in the window
}
// Filter out already-sent reminders
const newReminders = allUpcoming.filter(intake => {
const key = `${intake.medName}:${intake.intakeTime.getTime()}`;
return !state.sentReminders.includes(key);
});
if (newReminders.length === 0) {
return; // All reminders already sent
}
logger.info(`[IntakeReminder] Sending reminder for ${newReminders.length} upcoming intakes...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
// Send email if enabled for intake reminders
if (emailEnabled) {
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders, language);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Email sent successfully`);
} else {
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
}
}
// Send Shoutrrr notification if enabled for intake reminders
if (shoutrrrEnabled) {
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
const message = newReminders
.map((i) => {
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
if (i.pillWeightMg) {
const totalMg = i.usage * i.pillWeightMg;
dosage += totalMg >= 1000 ? ` (${(totalMg / 1000).toFixed(1)} g)` : ` (${totalMg} mg)`;
}
return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
})
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Push notification sent successfully`);
} else {
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
}
}
// Update state if any notification was sent successfully
if (emailSuccess || shoutrrrSuccess) {
const newKeys = newReminders.map(i => `${i.medName}:${i.intakeTime.getTime()}`);
// Clean up old entries (older than 24 hours)
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
const cleanedReminders = state.sentReminders.filter(key => {
const timestamp = parseInt(key.split(":").pop() || "0", 10);
return timestamp > oneDayAgo;
});
saveIntakeReminderState({
sentReminders: [...cleanedReminders, ...newKeys],
});
// Update global reminder state for UI display
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
updateReminderSentTime("intake", channel);
}
}
let intakeCheckInterval: NodeJS.Timeout | null = null;
export function startIntakeReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
logger.info(`[IntakeReminder] Starting intake reminder scheduler (checks every minute)...`);
// Run immediately on start
checkAndSendIntakeReminders(logger).catch((err) => logger.error(`[IntakeReminder] Error: ${err}`));
// Then run every minute
intakeCheckInterval = setInterval(() => {
checkAndSendIntakeReminders(logger).catch((err) => logger.error(`[IntakeReminder] Error: ${err}`));
}, CHECK_INTERVAL_MS);
logger.info(`[IntakeReminder] Scheduler started - checking every minute for upcoming intakes`);
}
export function stopIntakeReminderScheduler(): void {
if (intakeCheckInterval) {
clearInterval(intakeCheckInterval);
intakeCheckInterval = null;
}
}