feat: add Shoutrrr push notification support and settings to reminders

This commit is contained in:
Daniel Volz
2025-12-20 23:02:04 +01:00
parent 55bfea1949
commit 522223cb01
4 changed files with 377 additions and 150 deletions
+93 -1
View File
@@ -12,12 +12,18 @@ type SettingsBody = {
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
};
type TestEmailBody = {
email: string;
};
type TestShoutrrrBody = {
url: string;
};
// Notification settings are stored in a JSON file (user-configurable)
// SMTP settings come from .env (admin-configured)
const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json");
@@ -30,6 +36,8 @@ type NotificationSettings = {
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
};
function loadNotificationSettings(): NotificationSettings {
@@ -44,18 +52,23 @@ function loadNotificationSettings(): NotificationSettings {
lowStockDays: saved.lowStockDays ?? 30,
normalStockDays: saved.normalStockDays ?? 90,
highStockDays: saved.highStockDays ?? 180,
shoutrrrEnabled: saved.shoutrrrEnabled ?? false,
shoutrrrUrl: saved.shoutrrrUrl ?? "",
};
}
} catch {
// ignore
}
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: false, shoutrrrUrl: "" };
}
function saveNotificationSettings(settings: NotificationSettings): void {
writeFileSync(notificationSettingsFile, JSON.stringify(settings, null, 2));
}
// Export for use in reminder scheduler
export { loadNotificationSettings };
export async function settingsRoutes(app: FastifyInstance) {
// Get settings - notification from JSON file, SMTP from process.env
app.get("/settings", async (_request, reply) => {
@@ -71,6 +84,8 @@ export async function settingsRoutes(app: FastifyInstance) {
lowStockDays: notification.lowStockDays,
normalStockDays: notification.normalStockDays,
highStockDays: notification.highStockDays,
shoutrrrEnabled: notification.shoutrrrEnabled,
shoutrrrUrl: notification.shoutrrrUrl,
// SMTP settings (admin-configured, from .env)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
@@ -97,6 +112,8 @@ export async function settingsRoutes(app: FastifyInstance) {
lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180,
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
shoutrrrUrl: body.shoutrrrUrl ?? "",
});
return reply.send({ success: true });
@@ -150,4 +167,79 @@ export async function settingsRoutes(app: FastifyInstance) {
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
}
});
// Test Shoutrrr/ntfy notification
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
const { url } = request.body;
if (!url) {
return reply.status(400).send({ error: "Notification URL is required" });
}
try {
const result = await sendShoutrrrNotification(url, "MedAssist Test", "This is a test notification from MedAssist. If you received this, your notification configuration is working correctly!");
if (result.success) {
return reply.send({ success: true, message: "Test notification sent successfully" });
} else {
return reply.status(500).send({ error: result.error });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
}
});
}
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
try {
// Parse the URL to determine the service
let targetUrl: string;
let method = "POST";
let headers: Record<string, string> = {};
let body: string | undefined;
// 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 };
body = message;
// Handle basic auth if present
if (parsed.username && parsed.password) {
headers["Authorization"] = "Basic " + Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
}
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
// Direct ntfy HTTPS URL
targetUrl = urlStr;
headers = { "Title": title };
body = message;
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
// Generic webhook URL - send as JSON
targetUrl = urlStr;
headers = { "Content-Type": "application/json" };
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
} else {
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
}
const response = await fetch(targetUrl, {
method,
headers,
body,
});
if (response.ok) {
return { success: true };
} else {
const errorText = await response.text();
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
+40 -30
View File
@@ -3,6 +3,7 @@ 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";
type Slice = { usage: number; every: number; start: string };
@@ -14,6 +15,8 @@ type NotificationSettings = {
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
};
type ReminderState = {
@@ -43,29 +46,8 @@ function getMsUntilNextCheck(): number {
return next.getTime() - Date.now();
}
const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json");
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
function loadNotificationSettings(): NotificationSettings {
try {
if (existsSync(notificationSettingsFile)) {
const saved = JSON.parse(readFileSync(notificationSettingsFile, "utf-8"));
return {
emailEnabled: saved.emailEnabled ?? false,
notificationEmail: saved.notificationEmail ?? "",
reminderDaysBefore: saved.reminderDaysBefore ?? 7,
repeatDailyReminders: saved.repeatDailyReminders ?? false,
lowStockDays: saved.lowStockDays ?? 30,
normalStockDays: saved.normalStockDays ?? 90,
highStockDays: saved.highStockDays ?? 180,
};
}
} catch {
// ignore
}
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
}
function loadReminderState(): ReminderState {
try {
if (existsSync(reminderStateFile)) {
@@ -265,9 +247,12 @@ Automatic reminder from MedAssist`;
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
// Check if email reminders are enabled
if (!settings.emailEnabled || !settings.notificationEmail) {
logger.info("[Reminder] Email reminders disabled or no email configured");
// Check if any notifications are enabled
const emailEnabled = settings.emailEnabled && settings.notificationEmail;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl;
if (!emailEnabled && !shoutrrrEnabled) {
logger.info("[Reminder] No notifications enabled");
return;
}
@@ -320,10 +305,38 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
logger.info(`[Reminder] Sending reminder for ${medsToNotify.length} medications...`);
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
let emailSuccess = false;
let shoutrrrSuccess = false;
if (result.success) {
// Update state (preserve nextScheduledCheck)
// Send email if enabled
if (emailEnabled) {
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
emailSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
} else {
logger.error(`[Reminder] Failed to send email: ${result.error}`);
}
}
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
const title = `⚠️ MedAssist: ${medsToNotify.length} Medication${medsToNotify.length > 1 ? "s" : ""} Running Low`;
const message = medsToNotify
.map((m) => `${m.name}: ${m.medsLeft} pills, ${m.daysLeft ?? 0} days left`)
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] Push notification sent successfully`);
} else {
logger.error(`[Reminder] Failed to send push notification: ${result.error}`);
}
}
// Update state if any notification was sent successfully
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
@@ -331,9 +344,6 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])],
nextScheduledCheck: currentState.nextScheduledCheck,
});
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
} else {
logger.error(`[Reminder] Failed to send email: ${result.error}`);
}
}