import { FastifyInstance } from "fastify"; import nodemailer from "nodemailer"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; import { loadUserSettings, sendShoutrrrNotification } from "./settings.js"; import { getDateLocale, getTranslations, t, type Language } from "../i18n/translations.js"; import type { AuthUser } from "../types/fastify.js"; import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; type PlannerRow = { medicationId: number; medicationName: string; totalPills: number; plannerUsage: number; stripSize: number; stripsNeeded: number; stripsAvailable: number; enough: boolean; }; type SendEmailBody = { email: string; from: string; until: string; rows: PlannerRow[]; language?: Language; // Optional: passed from frontend for unauthenticated requests }; type LowStockItem = { name: string; medsLeft: number; daysLeft: number | null; depletionDate: string | null; }; type ReminderEmailBody = { email: string; lowStock: LowStockItem[]; language?: Language; // Optional: passed from frontend for unauthenticated requests }; export async function plannerRoutes(app: FastifyInstance) { // Add auth hook for all planner routes app.addHook("preHandler", requireAuth); // Helper to get user ID from request async function getUserId(request: any): Promise { if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } const authUser = request.user as AuthUser | null; if (!authUser?.id) { throw new Error("User not authenticated"); } return authUser.id; } app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => { const { email, from, until, rows, language: bodyLanguage } = request.body; if (!email || !rows || rows.length === 0) { return reply.status(400).send({ error: "Missing email or planner data" }); } 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 reply.status(400).send({ error: "SMTP not configured" }); } // Get locale from user settings or use the language passed in the body let language: Language = bodyLanguage || "en"; const authUser = request.user as unknown as AuthUser | null; if (authUser?.id) { const userSettings = await loadUserSettings(authUser.id); language = userSettings.language; } const locale = getDateLocale(language); // Format dates for display const fromDate = new Date(from).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", }); const untilDate = new Date(until).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", }); // Build HTML table with horizontal scroll for mobile const tableRows = rows .map( (row) => ` ${row.medicationName} ${row.totalPills} ${row.plannerUsage} ${row.stripsNeeded} × ${row.stripSize} ${row.stripsAvailable} ${row.enough ? "✓ OK" : "✗ Out of Stock"} ` ) .join(""); const outOfStockCount = rows.filter((r) => !r.enough).length; const summaryText = outOfStockCount > 0 ? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.` : "✓ All medications have sufficient supply for this period."; const html = `

MedAssist-ng - Demand Calculator

Supply overview from ${fromDate} to ${untilDate}

${summaryText}

${tableRows}
Medication Stock Usage Needed Available Status

Sent from MedAssist-ng Medication Planner

`; const plainText = `MedAssist-ng - Demand Calculator Supply overview from ${fromDate} to ${untilDate} ${summaryText} ${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.stripsAvailable} blisters available (${r.stripsNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")} --- Sent from MedAssist-ng Medication Planner`; try { const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpSecure, auth: { user: smtpUser, pass: smtpPass ?? "", }, }); await transporter.sendMail({ from: smtpFrom, to: email, subject: `MedAssist-ng - Supply Overview (${fromDate} - ${untilDate})`, text: plainText, html, }); return reply.send({ success: true, message: "Email sent successfully" }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` }); } }); // Reminder notification for low stock medications (supports email and push) app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => { const { email, lowStock, language: bodyLanguage } = request.body; if (!lowStock || lowStock.length === 0) { return reply.status(400).send({ error: "Missing low stock data" }); } // Load user settings const userId = await getUserId(request); const userSettings = await loadUserSettings(userId); const notificationSettings = { emailEnabled: userSettings.emailEnabled, shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl || "", }; const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; // Separate empty from low stock medications const emptyMeds = lowStock.filter(r => r.medsLeft <= 0); const lowMeds = lowStock.filter(r => r.medsLeft > 0); // Send email if enabled if (notificationSettings.emailEnabled && email) { 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) { // Build subject line based on what we have let subjectText: string; if (emptyMeds.length > 0 && lowMeds.length > 0) { subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`; } else if (emptyMeds.length > 0) { subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`; } else { subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`; } // Build alert box based on what we have let alertHtml: string; if (emptyMeds.length > 0 && lowMeds.length > 0) { alertHtml = `

🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!

⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon

`; } else if (emptyMeds.length > 0) { alertHtml = `

🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!

`; } else { alertHtml = `

⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon

`; } // Build table rows with status indicator const buildTableRow = (row: LowStockItem) => { const isEmpty = row.medsLeft <= 0; const statusIcon = isEmpty ? "🚨" : "⚠️"; const rowBg = isEmpty ? "#fef2f2" : "white"; return ` ${statusIcon} ${row.name} ${row.medsLeft} ${row.daysLeft ?? 0} ${isEmpty ? "NOW" : (row.depletionDate ?? "-")} `; }; const tableRows = lowStock.map(buildTableRow).join(""); // Build description text let descriptionText: string; if (emptyMeds.length > 0 && lowMeds.length > 0) { descriptionText = "The following medications need to be reordered:"; } else if (emptyMeds.length > 0) { descriptionText = "The following medications are EMPTY and need to be reordered immediately:"; } else { descriptionText = "The following medications are running low and need to be reordered:"; } const html = `

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder

${descriptionText}

${alertHtml}
${tableRows}
Medication Pills Days Runs Out

Sent from MedAssist-ng Medication Planner

`; // Build plain text with sections let plainTextContent: string; if (emptyMeds.length > 0 && lowMeds.length > 0) { plainTextContent = `🚨 EMPTY (reorder immediately): ${emptyMeds.map((r) => ` • ${r.name}`).join("\n")} ⚠️ RUNNING LOW (reorder soon): ${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`; } else if (emptyMeds.length > 0) { plainTextContent = `🚨 EMPTY (reorder immediately): ${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}`; } else { plainTextContent = `⚠️ Running low: ${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`; } const plainText = `MedAssist-ng - Reorder Reminder ${plainTextContent} --- Sent from MedAssist-ng Medication Planner`; try { const transporter = nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpSecure, auth: { user: smtpUser, pass: smtpPass ?? "", }, }); await transporter.sendMail({ from: smtpFrom, to: email, subject: `MedAssist-ng - ${subjectText}`, text: plainText, html, }); results.email = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } } } // Send push notification if enabled if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { // Get translations based on user language (default to 'en') const tr = getTranslations((userSettings.language as Language) || "en"); // Build clear title const titleParts: string[] = []; if (emptyMeds.length > 0) { titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); } if (lowMeds.length > 0) { titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`); } const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; // Build clear message with sections const messageParts: string[] = []; if (emptyMeds.length > 0) { messageParts.push(`🚨 ${tr.push.emptySection}:`); emptyMeds.forEach(r => messageParts.push(` • ${r.name}`)); } if (lowMeds.length > 0) { if (emptyMeds.length > 0) messageParts.push(""); messageParts.push(`⚠️ ${tr.push.lowSection}:`); lowMeds.forEach(r => messageParts.push(` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`)); } const message = messageParts.join("\n"); try { const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message); if (pushResult.success) { results.push = true; } else { results.errors.push(`Push: ${pushResult.error}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Push: ${errorMessage}`); } } // Update the reminder state to record this notification was sent if (results.email || results.push) { const channel = results.email && results.push ? "both" : results.email ? "email" : "push"; updateReminderSentTime("stock", channel); // Also update user settings in database so frontend can display the info await updateUserReminderSentTime(userId, "stock", channel); } // Build response message const sentChannels: string[] = []; if (results.email) sentChannels.push("email"); if (results.push) sentChannels.push("push"); if (sentChannels.length > 0) { return reply.send({ success: true, message: `Reminder sent via ${sentChannels.join(" and ")}` }); } else if (results.errors.length > 0) { return reply.status(500).send({ error: results.errors.join("; ") }); } else { return reply.status(400).send({ error: "No notification channels configured" }); } }); }