import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyRequest } from "fastify"; import { db } from "../db/client.js"; import { medications } from "../db/schema.js"; import { getDateLocale, getFooterHtml, getFooterPlain, getTranslations, type Language, t, } from "../i18n/translations.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { buildPrescriptionReminderPushNotification, buildStockReminderPushNotification, type PrescriptionReminderItem as SharedPrescriptionReminderItem, type StockReminderItem as SharedStockReminderItem, } from "../services/notifications/builders.js"; import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js"; import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; import type { AuthUser } from "../types/fastify.js"; import { applyOpenApiRouteStandards, genericErrorSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { isTubePackageType, normalizePackageType } from "../utils/package-profiles.js"; import { loadUserSettings, sendShoutrrrNotification } from "./settings.js"; type PlannerRow = { medicationId: number; medicationName: string; totalPills: number; plannerUsage: number; blisterSize: number; blistersNeeded: number; fullBlisters: number; loosePills: number; enough: boolean; packageType?: string; }; 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; isCritical?: boolean; }; type ReminderEmailBody = { email: string; lowStock: LowStockItem[]; language?: Language; // Optional: passed from frontend for unauthenticated requests }; type PrescriptionReminderItem = { name: string; remainingRefills: number; threshold: number; expiryDate?: string | null; }; type PrescriptionReminderBody = { email: string; prescriptionLow: PrescriptionReminderItem[]; language?: Language; }; const plannerRowSchema = { type: "object", required: [ "medicationId", "medicationName", "totalPills", "plannerUsage", "blisterSize", "blistersNeeded", "fullBlisters", "loosePills", "enough", ], properties: { medicationId: { type: "integer" }, medicationName: { type: "string" }, totalPills: { type: "number" }, plannerUsage: { type: "number" }, blisterSize: { type: "number" }, blistersNeeded: { type: "number" }, fullBlisters: { type: "number" }, loosePills: { type: "number" }, enough: { type: "boolean" }, packageType: { type: "string" }, }, } as const; const lowStockItemSchema = { type: "object", required: ["name", "medsLeft"], properties: { name: { type: "string" }, medsLeft: { type: "number" }, daysLeft: { type: "number" }, depletionDate: { type: "string" }, isCritical: { type: "boolean" }, }, } as const; const prescriptionReminderItemSchema = { type: "object", required: ["name", "remainingRefills", "threshold"], properties: { name: { type: "string" }, remainingRefills: { type: "integer" }, threshold: { type: "integer" }, expiryDate: { type: "string" }, }, } as const; const notificationResponseSchema = { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" }, }, } as const; export async function plannerRoutes(app: FastifyInstance) { // Add auth hook for all planner routes app.addHook("preHandler", requireAuth); applyOpenApiRouteStandards(app, { tag: "planner", protectedByDefault: true }); // Helper to get user ID from request async function getUserId(request: FastifyRequest): Promise { if (!env.AUTH_ENABLED) { return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; if (!authUser?.id) { throw new Error("User not authenticated"); } return authUser.id; } // Demand calculator notification (supports email and push) app.post<{ Body: SendEmailBody }>( "/planner/send-email", { schema: { body: { type: "object", properties: { email: { type: "string" }, from: { type: "string" }, until: { type: "string" }, language: { type: "string" }, rows: { type: "array", items: plannerRowSchema }, }, example: { email: "daniel@example.com", from: "2026-03-11", until: "2026-04-11", language: "en", rows: [ { medicationId: 1, medicationName: "Ibuprofen 400", totalPills: 20, plannerUsage: 12, blisterSize: 10, blistersNeeded: 2, fullBlisters: 1, loosePills: 8, enough: true, packageType: "box", }, ], }, }, response: { 200: notificationResponseSchema, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, 500: genericErrorSchema, }, }, }, async (request, reply) => { const { email, from, until, rows, language: bodyLanguage } = request.body; request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received"); if (!rows || rows.length === 0) { return reply.status(400).send({ error: "Missing planner data" }); } // Load user settings for notification channels const userId = await getUserId(request); const activeMeds = await db .select({ id: medications.id }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); const activeMedIds = new Set(activeMeds.map((med) => med.id)); const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId)); if (activeRows.length === 0) { request.log.warn("[Planner] Demand notification skipped: no active medications in request"); return reply.status(400).send({ error: "No active medications to notify" }); } const activeMedicationNames = activeRows.map((row) => row.medicationName); const userSettings = await loadUserSettings(userId); const notificationSettings = { emailEnabled: userSettings.emailEnabled, shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl || "", }; request.log.info( { userId, emailEnabled: notificationSettings.emailEnabled, pushEnabled: notificationSettings.shoutrrrEnabled, hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), activeRowCount: activeRows.length, recipientEmail: email, medications: activeMedicationNames, }, "[Planner] Demand notification channel state" ); // Get locale from user settings or use the language passed in the body const language: Language = (userSettings.language as Language) || bodyLanguage || "en"; const locale = getDateLocale(language); const tr = getTranslations(language); const dc = tr.demandCalculator; // Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe const fromDate = escapeHtml( new Date(from).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", }) ); const untilDate = escapeHtml( new Date(until).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", }) ); const outOfStockCount = activeRows.filter((r) => !r.enough).length; const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk; // Load prescription data for medications referenced in planner rows const medIds = activeRows.map((r) => r.medicationId).filter(Boolean); const allMeds = medIds.length > 0 ? await db .select({ id: medications.id, prescriptionEnabled: medications.prescriptionEnabled, prescriptionRemainingRefills: medications.prescriptionRemainingRefills, }) .from(medications) .where(eq(medications.userId, userId)) : []; const prescriptionMap = new Map(allMeds.map((m) => [m.id, m])); // Build plain text (shared between email and push) const plainText = `${dc.title} ${t(dc.description, { from: fromDate, until: untilDate })} ${summaryText} ${activeRows .map((r) => { const isBottle = isContainerPackage(r.packageType); const usageUnit = getPlannerUnit(r.packageType, tr); const usage = `${r.plannerUsage} ${usageUnit}`; const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`; const medPrescription = prescriptionMap.get(r.medicationId); const rxRefills = medPrescription?.prescriptionEnabled ? String(medPrescription.prescriptionRemainingRefills ?? 0) : dc.prescriptionNotApplicable; const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10; const availableUnit = getPlannerUnit(r.packageType, tr); const available = isBottle ? `${loosePills} ${availableUnit}` : `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`; const status = r.enough ? dc.statusEnough : dc.statusEmpty; return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`; }) .join("\n")} --- ${getFooterPlain(language)}`; const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; // 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", 10); const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; request.log.info( { userId, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser), hasSmtpPass: Boolean(smtpPass), smtpPort, smtpSecure, hasSmtpFrom: Boolean(smtpFrom), recipientEmail: email, }, "[Planner] Demand email path selected" ); if (smtpHost && smtpUser) { // Build HTML table with horizontal scroll for mobile // Escape/coerce all user-provided values to prevent XSS const tableRows = activeRows .map((row) => { const safeName = escapeHtml(row.medicationName); const safePlannerUsage = Number(row.plannerUsage) || 0; const safeBlistersNeeded = Number(row.blistersNeeded) || 0; const safeBlisterSize = Number(row.blisterSize) || 0; const safeFullBlisters = Number(row.fullBlisters) || 0; const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10; const isBottle = isContainerPackage(row.packageType); // "Blisters needed" column: dash for bottles const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`; // "Prescription refills" column const medPrescription = prescriptionMap.get(row.medicationId); const rxCell = medPrescription?.prescriptionEnabled ? String(medPrescription.prescriptionRemainingRefills ?? 0) : dc.prescriptionNotApplicable; // "Available" column: match frontend format let availableCell: string; if (isBottle) { const availableUnit = getPlannerUnit(row.packageType, tr); availableCell = `${safeLoosePills} ${availableUnit}`; } else { availableCell = `${safeFullBlisters} ${tr.common.blisters}`; if (safeLoosePills > 0) { availableCell += ` + ${safeLoosePills} ${tr.common.pills}`; } } const rowBg = row.enough ? "" : " background: #fef2f2;"; return ` ${safeName} ${safePlannerUsage} ${getPlannerUnit(row.packageType, tr)} ${neededCell} ${rxCell} ${availableCell} ${row.enough ? dc.statusEnough : dc.statusEmpty} `; }) .join(""); const html = `

${dc.title}

${t(dc.description, { from: `${fromDate}`, until: `${untilDate}` })}

${summaryText}

${tableRows}
${dc.tableHeaders.medication} ${dc.tableHeaders.usage} ${dc.tableHeaders.needed} ${dc.tableHeaders.prescriptionRefills} ${dc.tableHeaders.available} ${dc.tableHeaders.status}

${getFooterHtml(language)}

`; try { request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email"); const mailResult = await sendEmailNotification({ from: smtpFrom, to: email, subject: t(dc.subject, { from: fromDate, until: untilDate }), text: plainText, html, }); if (!mailResult.success) { throw new Error(mailResult.error ?? "Failed to send demand email"); } request.log.info( { userId, recipientEmail: email, messageId: mailResult.messageId }, "[Planner] Demand email sent" ); results.email = true; } catch (error) { request.log.error({ userId, recipientEmail: email, error }, "[Planner] Demand email failed"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } } else { request.log.warn( { userId, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser), recipientEmail: email, }, "[Planner] Demand email skipped: SMTP not configured" ); } } else { request.log.info( { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, "[Planner] Demand email channel not active" ); } // Send push notification if enabled if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { const pushTitle = t(dc.subject, { from: fromDate, until: untilDate }); const pushMessage = `${summaryText}\n\n${activeRows .map((r) => { const usage = `${r.plannerUsage} ${getPlannerUnit(r.packageType, tr)}`; const status = r.enough ? dc.statusEnough : dc.statusEmpty; return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`; }) .join("\n")}\n\n---\n${getFooterPlain(language)}`; try { const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, pushTitle, pushMessage); 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}`); } } // 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: `Notification 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" }); } } ); // Reminder notification for low stock medications (supports email and push) app.post<{ Body: ReminderEmailBody }>( "/reminder/send-email", { schema: { body: { type: "object", properties: { email: { type: "string" }, language: { type: "string" }, lowStock: { type: "array", items: lowStockItemSchema }, }, example: { email: "daniel@example.com", language: "en", lowStock: [ { name: "Ibuprofen 400", medsLeft: 4, daysLeft: 2, depletionDate: "2026-03-13", isCritical: true, }, ], }, }, response: { 200: notificationResponseSchema, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, 500: genericErrorSchema, }, }, }, async (request, reply) => { const { email, lowStock } = request.body; request.log.info( { email, lowStockCount: lowStock?.length ?? 0 }, "[ReminderManual] Stock reminder request received" ); if (!lowStock || lowStock.length === 0) { return reply.status(400).send({ error: "Missing low stock data" }); } // Load user settings const userId = await getUserId(request); const activeMeds = await db .select({ name: medications.name, genericName: medications.genericName, packageType: medications.packageType }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); const activeMedicationByName = new Map( activeMeds .map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const) .filter(([name]) => name.length > 0) ); const filteredLowStock = lowStock.filter((item) => { const packageType = activeMedicationByName.get(item.name); if (!packageType) return false; if (isTubePackageType(packageType)) return false; return true; }); if (filteredLowStock.length === 0) { request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering"); return reply.status(400).send({ error: "No active medications to notify" }); } const filteredMedicationNames = filteredLowStock.map((item) => item.name); const userSettings = await loadUserSettings(userId); const notificationSettings = { emailEnabled: userSettings.emailEnabled, shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl || "", }; request.log.info( { userId, emailEnabled: notificationSettings.emailEnabled, pushEnabled: notificationSettings.shoutrrrEnabled, hasPushUrl: Boolean(notificationSettings.shoutrrrUrl), filteredLowStockCount: filteredLowStock.length, recipientEmail: email, medications: filteredMedicationNames, }, "[ReminderManual] Stock reminder channel state" ); // Get translations based on user language const language = (userSettings.language as Language) || "en"; const tr = getTranslations(language); const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; // Separate into 3 categories: empty, critical, and low stock const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0); const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false); const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false); // Build shared notification content (method-agnostic) 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}`); } // 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 section-based message (shared between email plain text and push) const messageParts: string[] = []; if (emptyMeds.length > 0) { messageParts.push(`🚨 ${tr.push.emptySection}:`); emptyMeds.forEach((r) => messageParts.push(` • ${r.name}`)); } if (criticalMeds.length > 0) { if (messageParts.length > 0) messageParts.push(""); messageParts.push(`🚨 ${tr.push.criticalSection}:`); criticalMeds.forEach((r) => messageParts.push( ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` ) ); } if (lowStockMeds.length > 0) { if (messageParts.length > 0) messageParts.push(""); messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); lowStockMeds.forEach((r) => messageParts.push( ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` ) ); } // Send email if enabled if (notificationSettings.emailEnabled && email) { const smtp = getSmtpConfig(); request.log.info( { userId, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user), hasSmtpPass: Boolean(smtp.pass), smtpPort: smtp.port, smtpSecure: smtp.secure, hasSmtpFrom: Boolean(smtp.from), recipientEmail: email, }, "[ReminderManual] Stock email path selected" ); if (smtp.host && smtp.user) { // Build subject line from shared title parts const subjectText = titleParts.join(", "); // Build alert boxes for each category 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 table rows with status indicator const buildTableRow = (row: LowStockItem) => { const isEmpty = row.medsLeft <= 0; const isCritical = row.isCritical !== false; const nonEmptyIcon = isCritical ? "🚨" : "⚠️"; const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; const nonEmptyBg = isCritical ? "#fff7ed" : "white"; const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; const safeName = escapeHtml(row.name); const safeMedsLeft = Number(row.medsLeft) || 0; const safeDaysLeft = Number(row.daysLeft) || 0; const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-"; return ` ${statusIcon} ${safeName} ${safeMedsLeft} ${safeDaysLeft} ${isEmpty ? `${tr.stockReminder.now}` : safeDepletionDate} `; }; const tableRows = filteredLowStock.map(buildTableRow).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)}

`; const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; try { request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email"); const mailResult = await sendEmailNotification({ to: email, subject: `MedAssist-ng: ${subjectText}`, text: plainText, html, from: smtp.from, }); if (!mailResult.success) { throw new Error(mailResult.error ?? "Unknown error"); } request.log.info( { userId, recipientEmail: email, messageId: mailResult.messageId }, "[ReminderManual] Stock reminder email sent" ); results.email = true; } catch (error) { request.log.error({ userId, recipientEmail: email, error }, "[ReminderManual] Stock reminder email failed"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } } else { request.log.warn( { userId, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user), recipientEmail: email, }, "[ReminderManual] Stock reminder email skipped: SMTP not configured" ); } } else { request.log.info( { emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) }, "[ReminderManual] Stock email channel not active" ); } // Send push notification if enabled if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { const pushPayload = buildStockReminderPushNotification(filteredLowStock as SharedStockReminderItem[], language); try { const pushResult = await sendPushNotification( notificationSettings.shoutrrrUrl, pushPayload.title, pushPayload.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 singleChannel = results.email ? "email" : "push"; const channel = results.email && results.push ? "both" : singleChannel; updateReminderSentTime("stock", channel); // Also update user settings in database so frontend can display the info const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", "); await updateUserReminderSentTime(userId, "stock", channel, medNames); } // 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" }); } } ); // Manual prescription reminder (supports email and push) app.post<{ Body: PrescriptionReminderBody }>( "/reminder/send-prescription", { schema: { body: { type: "object", properties: { email: { type: "string" }, language: { type: "string" }, prescriptionLow: { type: "array", items: prescriptionReminderItemSchema }, }, example: { email: "daniel@example.com", language: "en", prescriptionLow: [ { name: "Ibuprofen 400", remainingRefills: 1, threshold: 1, expiryDate: "2026-06-30", }, ], }, }, response: { 200: notificationResponseSchema, 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, 401: genericErrorSchema, 500: genericErrorSchema, }, }, }, async (request, reply) => { const { email, prescriptionLow } = request.body; request.log.info( { email, prescriptionCount: prescriptionLow?.length ?? 0 }, "[ReminderManual] Prescription reminder request received" ); if (!prescriptionLow || prescriptionLow.length === 0) { return reply.status(400).send({ error: "Missing prescription reminder data" }); } const userId = await getUserId(request); const activeMeds = await db .select({ name: medications.name, genericName: medications.genericName }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name)); if (filteredPrescriptionLow.length === 0) { request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering"); return reply.status(400).send({ error: "No active medications to notify" }); } const filteredMedicationNames = filteredPrescriptionLow.map((item) => item.name); const userSettings = await loadUserSettings(userId); const language = (userSettings.language as Language) || "en"; const tr = getTranslations(language); request.log.info( { userId, emailEnabled: userSettings.emailEnabled, pushEnabled: userSettings.shoutrrrEnabled, hasPushUrl: Boolean(userSettings.shoutrrrUrl), prescriptionCount: filteredPrescriptionLow.length, recipientEmail: email, medications: filteredMedicationNames, }, "[ReminderManual] Prescription reminder channel state" ); const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0); const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0); const lines = filteredPrescriptionLow.map((item) => { const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : ""; if (item.remainingRefills <= 0) { return `- ${t(tr.prescriptionReminder.lineEmpty, { name: item.name, expirySuffix, })}`; } return `- ${t(tr.prescriptionReminder.line, { name: item.name, refills: item.remainingRefills, expirySuffix, })}`; }); const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", "); const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) { const smtp = getSmtpConfig(); request.log.info( { userId, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user), hasSmtpPass: Boolean(smtp.pass), smtpPort: smtp.port, smtpSecure: smtp.secure, hasSmtpFrom: Boolean(smtp.from), recipientEmail: email, }, "[ReminderManual] Prescription email path selected" ); if (smtp.host && smtp.user) { try { const subject = filteredPrescriptionLow.length === 1 ? tr.prescriptionReminder.subjectSingle : t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.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 = filteredPrescriptionLow .map((item) => { const isEmpty = item.remainingRefills <= 0; const safeName = escapeHtml(item.name); const safeRefills = Number(item.remainingRefills) || 0; const safeThreshold = Number(item.threshold) || 0; const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-"; const rowBg = isEmpty ? "#fef2f2" : "white"; return ` ${isEmpty ? "🚨" : "⚠️"} ${safeName} ${safeRefills} ${safeThreshold} ${safeExpiry} `; }) .join(""); const emailTitle = emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title; const text = `${emailTitle}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}`; const html = `

${emailTitle}

${bodyText}

${alertText}

${tableRows}
${tr.prescriptionReminder.tableHeaders.medication} ${tr.prescriptionReminder.tableHeaders.refillsLeft} ${tr.prescriptionReminder.tableHeaders.reminderThreshold} ${tr.prescriptionReminder.tableHeaders.prescriptionExpires}

${getFooterHtml(language)}

`; request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email"); const mailResult = await sendEmailNotification({ to: email, subject, text, html, from: smtp.from, }); if (!mailResult.success) { throw new Error(mailResult.error ?? "Unknown error"); } request.log.info( { userId, recipientEmail: email, messageId: mailResult.messageId }, "[ReminderManual] Prescription reminder email sent" ); results.email = true; } catch (error) { request.log.error( { userId, recipientEmail: email, error }, "[ReminderManual] Prescription reminder email failed" ); const errorMessage = error instanceof Error ? error.message : "Unknown error"; results.errors.push(`Email: ${errorMessage}`); } } else { request.log.warn( { userId, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user), recipientEmail: email, }, "[ReminderManual] Prescription reminder email skipped: SMTP not configured" ); } } else { request.log.info( { emailEnabled: userSettings.emailEnabled, emailPrescriptionReminders: userSettings.emailPrescriptionReminders, hasRecipient: Boolean(email), }, "[ReminderManual] Prescription email channel not active" ); } if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) { const pushPayload = buildPrescriptionReminderPushNotification( filteredPrescriptionLow as SharedPrescriptionReminderItem[], language ); try { const pushResult = await sendPushNotification( userSettings.shoutrrrUrl, pushPayload.title, pushPayload.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}`); } } if (results.email || results.push) { const singleChannel = results.email ? "email" : "push"; const channel = results.email && results.push ? "both" : singleChannel; updateReminderSentTime("prescription", channel); await updateUserReminderSentTime(userId, "prescription", channel, medNames); } 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: `Prescription reminder sent via ${sentChannels.join(" and ")}`, }); } if (results.errors.length > 0) { return reply.status(500).send({ error: results.errors.join("; ") }); } return reply.status(400).send({ error: "No notification channels configured" }); } ); }