From 221811ed7cd676bed79ffc3e6329aa05e87d3231 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 21 Dec 2025 09:52:48 +0100 Subject: [PATCH] feat: add granular notification settings for email and Shoutrrr reminders --- backend/src/routes/settings.ts | 41 +- .../src/services/intake-reminder-scheduler.ts | 12 +- backend/src/services/reminder-scheduler.ts | 13 +- frontend/src/App.tsx | 400 ++++++++++-------- frontend/src/styles.css | 286 +++++++++++-- 5 files changed, 545 insertions(+), 207 deletions(-) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 3488d03..5d915dd 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -14,6 +14,11 @@ type SettingsBody = { highStockDays: number; shoutrrrEnabled: boolean; shoutrrrUrl: string; + // Granular notification settings + emailStockReminders: boolean; + emailIntakeReminders: boolean; + shoutrrrStockReminders: boolean; + shoutrrrIntakeReminders: boolean; }; type TestEmailBody = { @@ -38,6 +43,11 @@ type NotificationSettings = { highStockDays: number; shoutrrrEnabled: boolean; shoutrrrUrl: string; + // Granular notification settings + emailStockReminders: boolean; + emailIntakeReminders: boolean; + shoutrrrStockReminders: boolean; + shoutrrrIntakeReminders: boolean; }; function loadNotificationSettings(): NotificationSettings { @@ -54,12 +64,31 @@ function loadNotificationSettings(): NotificationSettings { highStockDays: saved.highStockDays ?? 180, shoutrrrEnabled: saved.shoutrrrEnabled ?? false, shoutrrrUrl: saved.shoutrrrUrl ?? "", + // Granular notification settings (default to true for backwards compatibility) + emailStockReminders: saved.emailStockReminders ?? true, + emailIntakeReminders: saved.emailIntakeReminders ?? true, + shoutrrrStockReminders: saved.shoutrrrStockReminders ?? true, + shoutrrrIntakeReminders: saved.shoutrrrIntakeReminders ?? true, }; } } catch { // ignore } - return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: false, shoutrrrUrl: "" }; + return { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + }; } function saveNotificationSettings(settings: NotificationSettings): void { @@ -86,6 +115,11 @@ export async function settingsRoutes(app: FastifyInstance) { highStockDays: notification.highStockDays, shoutrrrEnabled: notification.shoutrrrEnabled, shoutrrrUrl: notification.shoutrrrUrl, + // Granular notification settings + emailStockReminders: notification.emailStockReminders, + emailIntakeReminders: notification.emailIntakeReminders, + shoutrrrStockReminders: notification.shoutrrrStockReminders, + shoutrrrIntakeReminders: notification.shoutrrrIntakeReminders, // SMTP settings (admin-configured, from .env) smtpHost: process.env.SMTP_HOST ?? "", smtpPort: parseInt(process.env.SMTP_PORT ?? "587"), @@ -114,6 +148,11 @@ export async function settingsRoutes(app: FastifyInstance) { highStockDays: body.highStockDays ?? 180, shoutrrrEnabled: body.shoutrrrEnabled ?? false, shoutrrrUrl: body.shoutrrrUrl ?? "", + // Granular notification settings + emailStockReminders: body.emailStockReminders ?? true, + emailIntakeReminders: body.emailIntakeReminders ?? true, + shoutrrrStockReminders: body.shoutrrrStockReminders ?? true, + shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, }); return reply.send({ success: true }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 895ef18..e6cd720 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -202,12 +202,12 @@ MedAssist Medication Planner`; async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise { const settings = loadNotificationSettings(); - // Check if any notifications are enabled - const emailEnabled = settings.emailEnabled && settings.notificationEmail; - const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl; + // 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 notifications enabled, skip silently + return; // No intake reminder notifications enabled, skip silently } // Get all medications with intake reminders enabled @@ -247,7 +247,7 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void let emailSuccess = false; let shoutrrrSuccess = false; - // Send email if enabled + // Send email if enabled for intake reminders if (emailEnabled) { const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders); emailSuccess = result.success; @@ -258,7 +258,7 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void } } - // Send Shoutrrr notification if enabled + // Send Shoutrrr notification if enabled for intake reminders if (shoutrrrEnabled) { const title = `Medication Reminder in ${REMINDER_MINUTES_BEFORE} min`; const message = newReminders diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index dfc9af2..dcf33f7 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -17,6 +17,11 @@ type NotificationSettings = { highStockDays: number; shoutrrrEnabled: boolean; shoutrrrUrl: string; + // Granular notification settings + emailStockReminders: boolean; + emailIntakeReminders: boolean; + shoutrrrStockReminders: boolean; + shoutrrrIntakeReminders: boolean; }; type ReminderState = { @@ -340,12 +345,12 @@ Automatic reminder from MedAssist`; async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise { const settings = loadNotificationSettings(); - // Check if any notifications are enabled - const emailEnabled = settings.emailEnabled && settings.notificationEmail; - const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl; + // Check if any stock reminder notifications are enabled (granular check) + const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders; + const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders; if (!emailEnabled && !shoutrrrEnabled) { - logger.info("[Reminder] No notifications enabled"); + logger.info("[Reminder] No stock reminder notifications enabled"); return; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9bf6d58..7f535d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -107,6 +107,11 @@ export default function App() { // Shoutrrr/ntfy settings shoutrrrEnabled: false, shoutrrrUrl: "", + // Granular notification settings + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, }); const [savedSettings, setSavedSettings] = useState(settings); const [settingsLoading, setSettingsLoading] = useState(false); @@ -926,116 +931,210 @@ export default function App() { -
-
-

Automatic Reminders

- Daily at 6:00 AM -
- {settingsLoading ? ( -

Loading settings...

- ) : ( -
-
-
- - + {settingsLoading ? ( +

Loading settings...

+ ) : ( + + {/* Notifications */} +
+
+

Notifications

+
+ +
+
+

Channels

-
-
- Next check - {settings.nextScheduledCheck ? new Date(settings.nextScheduledCheck).toLocaleString([], { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"} +
+
+
+
Email
+
Push
- {settings.lastAutoEmailSent && ( -
- Last sent - {new Date(settings.lastAutoEmailSent).toLocaleString([], { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })} +
+
Stock Reminders
+
+
- )} +
+ +
+
+
+
Intake Reminders
+
+ +
+
+ +
+
+ {!settings.emailEnabled && !settings.shoutrrrEnabled && ( +

Enable at least one channel below to receive notifications.

+ )}
-

📊 Stock Display

-
-
- -
+ {settings.emailEnabled && ( + <> +
+ +
+
+ + SMTP: {settings.smtpHost || "Not configured"}:{settings.smtpPort} + {settings.hasSmtpPassword && " ✓"} + +
+
+ + {testEmailResult && ( + + {testEmailResult.message} + + )} +
+ + )}
-

⚙️ Reminder Threshold

- +

Push

+ +
+ {settings.shoutrrrEnabled && ( + <> +
+ +
+
+ + Supports ntfy, Discord, Telegram, Slack + +
+
+ + {testShoutrrrResult && ( + + {testShoutrrrResult.message} + + )} +
+ + )} +
+ +
+
+ Stock check + Daily at 6:00 AM +
+
+ Intake check + 15 min before scheduled time +
+ {settings.nextScheduledCheck && ( +
+ Next stock check + {new Date(settings.nextScheduledCheck).toLocaleString([], { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })} +
+ )} + {settings.lastAutoEmailSent && ( +
+ Last sent + {new Date(settings.lastAutoEmailSent).toLocaleString([], { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })} +
+ )} +
+
+ + {/* Stock Settings */} +
+
+

Stock

+
+ +
+
+

Reminder Threshold

+
+ +
+ +
+ + )} } /> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 71aa84a..65e889f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -998,6 +998,265 @@ textarea { gap: 0.75rem; } +/* Notification Matrix */ +.notification-matrix { + background: var(--bg-input); + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border-primary); +} + +.matrix-header { + display: grid; + grid-template-columns: 1fr 80px 80px; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); +} + +.matrix-header .matrix-label { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.matrix-channel { + text-align: center; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary); +} + +.matrix-row { + display: grid; + grid-template-columns: 1fr 80px 80px; + gap: 0.5rem; + padding: 0.75rem 1rem; + align-items: center; +} + +.matrix-row:not(:last-child) { + border-bottom: 1px solid var(--border-primary); +} + +.matrix-row .matrix-label { + font-size: 0.9rem; + color: var(--text-primary); +} + +.matrix-cell { + display: flex; + justify-content: center; + align-items: center; +} + +.matrix-cell .toggle-switch.small input:disabled + .toggle-slider { + opacity: 0.4; + cursor: not-allowed; +} + +.hint-text { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--warning-bg); + border-radius: 6px; +} + +/* Settings Grid - Two column layout */ +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 900px) { + .settings-grid { + grid-template-columns: 1fr; + } +} + +/* Notification Channels Grid */ +.notification-channels-grid { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.notification-channel { + background: var(--bg-input); + border: 2px solid var(--border-primary); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.2s; +} + +.notification-channel.enabled { + border-color: var(--accent); +} + +.channel-header { + padding: 0; +} + +.channel-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 1rem 1.25rem; + background: transparent; + border: none; + cursor: pointer; + transition: background 0.2s; +} + +.channel-toggle:hover { + background: var(--accent-bg); +} + +.channel-toggle.active { + background: var(--accent-bg); +} + +.channel-icon { + font-size: 1.5rem; +} + +.channel-name { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + flex: 1; + text-align: left; +} + +.channel-badge { + font-size: 0.7rem; + font-weight: 700; + padding: 0.25rem 0.6rem; + border-radius: 4px; + text-transform: uppercase; +} + +.channel-badge.on { + background: var(--success-bg); + color: var(--success); +} + +.channel-badge.off { + background: var(--bg-primary); + color: var(--text-secondary); +} + +.channel-content { + padding: 0 1.25rem 1.25rem; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); +} + +.channel-config { + padding-top: 1rem; +} + +.channel-config label { + display: block; +} + +.channel-config .smtp-info { + margin-top: 0.5rem; +} + +.channel-reminder-types { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + padding: 1rem 0; + border-top: 1px solid var(--border-primary); + margin-top: 1rem; +} + +.reminder-types-label { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; + width: 100%; + margin-bottom: 0.25rem; +} + +.reminder-type-option { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--bg-input); + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + font-size: 0.9rem; +} + +.reminder-type-option:hover { + background: var(--accent-bg); +} + +.reminder-type-option input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--accent); +} + +.reminder-type-option span { + color: var(--text-primary); +} + +.channel-actions { + display: flex; + align-items: center; + gap: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-primary); + margin-top: 1rem; +} + +/* Schedule Overview */ +.schedule-overview { + background: var(--bg-input); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.schedule-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + font-size: 0.85rem; +} + +.schedule-row:not(:last-child) { + border-bottom: 1px solid var(--border-primary); + padding-bottom: 0.5rem; + margin-bottom: 0.35rem; +} + +.schedule-label { + color: var(--text-secondary); +} + +.schedule-value { + color: var(--text-primary); + font-weight: 500; +} + +/* Legacy support for old channel-btn (can remove later) */ .channel-btn { display: flex; flex-direction: column; @@ -1022,18 +1281,6 @@ textarea { background: var(--accent-bg); } -.channel-icon { - font-size: 1.5rem; -} - -.channel-name { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--text-secondary); -} - .channel-status { font-size: 0.65rem; font-weight: 700; @@ -1058,24 +1305,11 @@ textarea { font-size: 0.8rem; } -.schedule-row { - display: flex; - gap: 0.5rem; -} - -.schedule-label { - color: var(--text-secondary); -} - -.schedule-value { - color: var(--text-primary); - font-weight: 500; -} - /* Section Header with Tooltip */ .section-header { display: flex; align-items: center; + justify-content: space-between; gap: 0.5rem; margin-bottom: 1rem; }