From 2054fc0b5634468662df6d126d06ec78706033ec Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 21 Dec 2025 08:40:06 +0100 Subject: [PATCH] feat: implement push notification support for low stock reminders and enhance email validation --- backend/src/routes/planner.ts | 203 ++++++++++++++++++++------------- backend/src/routes/settings.ts | 7 +- frontend/src/App.tsx | 120 ++++++++++--------- frontend/src/styles.css | 117 +++++-------------- 4 files changed, 220 insertions(+), 227 deletions(-) diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 87cdc24..f63a905 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -1,6 +1,7 @@ import { FastifyInstance } from "fastify"; import nodemailer from "nodemailer"; import { updateReminderSentTime } from "../services/reminder-scheduler.js"; +import { loadNotificationSettings, sendShoutrrrNotification } from "./settings.js"; type PlannerRow = { medicationId: number; @@ -169,74 +170,76 @@ Sent from MedAssist Medication Planner`; } }); - // Reminder email for low stock medications + // Reminder notification for low stock medications (supports email and push) app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => { const { email, lowStock } = request.body; - if (!email || !lowStock || lowStock.length === 0) { - return reply.status(400).send({ error: "Missing email or low stock data" }); + if (!lowStock || lowStock.length === 0) { + return reply.status(400).send({ error: "Missing low stock data" }); } - 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; + const notificationSettings = loadNotificationSettings(); + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; - if (!smtpHost || !smtpUser) { - return reply.status(400).send({ error: "SMTP not configured" }); - } + // 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_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587"); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; - // Build HTML table with horizontal scroll for mobile - const tableRows = lowStock - .map( - (row) => ` - - ${row.name} - ${row.medsLeft} - ${row.daysLeft ?? 0} - ${row.depletionDate ?? "-"} - - ` - ) - .join(""); + if (smtpHost && smtpUser) { + // Build HTML table with horizontal scroll for mobile + const tableRows = lowStock + .map( + (row) => ` + + ${row.name} + ${row.medsLeft} + ${row.daysLeft ?? 0} + ${row.depletionDate ?? "-"} + + ` + ) + .join(""); - const html = ` -
-
-

⚠️ MedAssist - Reorder Reminder

-

The following medications are running low and need to be reordered:

- -
-

- ⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low! -

+ const html = ` +
+
+

⚠️ MedAssist - Reorder Reminder

+

The following medications are running low and need to be reordered:

+ +
+

+ ⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low! +

+
+ +
+ + + + + + + + + + + ${tableRows} + +
MedicationPillsDaysRuns Out
+
+ +
+

Sent from MedAssist Medication Planner

+
+ `; -
- - - - - - - - - - - ${tableRows} - -
MedicationPillsDaysRuns Out
-
- -
-

Sent from MedAssist Medication Planner

-
-
- `; - - const plainText = `MedAssist - Reorder Reminder + const plainText = `MedAssist - Reorder Reminder The following medications are running low: @@ -245,32 +248,72 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} d --- Sent from MedAssist Medication Planner`; - try { - const transporter = nodemailer.createTransport({ - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - auth: { - user: smtpUser, - pass: smtpPass ?? "", - }, - }); + 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 - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`, - text: plainText, - html, - }); + await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: `⚠️ MedAssist - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`, + text: plainText, + html, + }); - // Update the reminder state to record this email was sent + 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) { + const title = `${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`; + const message = lowStock + .map((r) => `- ${r.name}: ${r.medsLeft} pills (${r.daysLeft ?? 0} days)`) + .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) { updateReminderSentTime(); + } - return reply.send({ success: true, message: "Reminder email sent" }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return reply.status(500).send({ error: `Failed to send email: ${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: `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" }); } }); } diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index b27b56d..3488d03 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -200,12 +200,15 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me let headers: Record = {}; let body: string | undefined; + // Remove emojis from title for header compatibility (ntfy doesn't support unicode in headers) + const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|⚠️/gu, "").trim(); + // Handle different URL formats if (urlStr.startsWith("ntfy://")) { // ntfy://[user:pass@]host/topic -> https://host/topic const parsed = new URL(urlStr.replace("ntfy://", "https://")); targetUrl = `https://${parsed.host}${parsed.pathname}`; - headers = { "Title": title }; + headers = { "Title": cleanTitle, "Tags": "warning" }; body = message; // Handle basic auth if present @@ -215,7 +218,7 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me } else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) { // Direct ntfy HTTPS URL targetUrl = urlStr; - headers = { "Title": title }; + headers = { "Title": cleanTitle, "Tags": "warning" }; body = message; } else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) { // Generic webhook URL - send as JSON diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 60d807c..dd497a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -233,6 +233,16 @@ export default function App() { async function saveSettings(e: React.FormEvent) { e.preventDefault(); + + // Validate email if email notifications are enabled + if (settings.emailEnabled && settings.notificationEmail) { + const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i; + if (!emailRegex.test(settings.notificationEmail)) { + setTestEmailResult({ success: false, message: "Invalid email address" }); + return; + } + } + setSettingsSaving(true); setTestEmailResult(null); @@ -531,13 +541,13 @@ export default function App() { } /> - {settings.emailEnabled && settings.notificationEmail && ( + {(settings.emailEnabled || settings.shoutrrrEnabled) && (
- 📧 + {settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"} Automatic reminders active — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)} - → {settings.notificationEmail} + {settings.emailEnabled && settings.notificationEmail && → {settings.notificationEmail}}
)}
@@ -576,10 +586,10 @@ export default function App() { ); })}
- {settings.emailEnabled && settings.notificationEmail && ( + {(settings.emailEnabled || settings.shoutrrrEnabled) && (
{reminderEmailResult && ( @@ -970,10 +980,44 @@ export default function App() {
+
+
+

📊 Stock Display

+
+
+ + +
+
+

⚙️ Reminder Threshold

- +