feat: enhance medication reminder system with improved notifications and user settings updates

- Added new translation keys for empty and low stock notifications in both English and German.
- Implemented user authentication for planner routes and improved user settings loading.
- Separated empty and low stock medications for clearer notifications.
- Enhanced email notifications with detailed alerts for empty and low stock medications.
- Updated user settings in the database when reminders are sent for both intake and stock notifications.
- Improved form validation in the frontend with character limits and error messages.
- Added CSS styles for form validation feedback and character count display.
This commit is contained in:
Daniel Volz
2025-12-28 14:42:51 +01:00
parent 30156ebd60
commit 78ee668c8b
8 changed files with 424 additions and 97 deletions
+15
View File
@@ -43,6 +43,11 @@ type TranslationKeys = {
daysLeft: string;
pillsAt: string;
repeatDailyNote: string;
empty: string;
low: string;
reorderNow: string;
emptySection: string;
lowSection: string;
};
// Common
common: {
@@ -94,6 +99,11 @@ const translations: Record<Language, TranslationKeys> = {
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
repeatDailyNote: "(Daily reminder enabled)",
empty: "Empty",
low: "Low",
reorderNow: "Reorder Now!",
emptySection: "EMPTY (reorder immediately)",
lowSection: "RUNNING LOW (reorder soon)",
},
common: {
pill: "pill",
@@ -142,6 +152,11 @@ const translations: Record<Language, TranslationKeys> = {
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
empty: "Leer",
low: "Knapp",
reorderNow: "Jetzt nachbestellen!",
emptySection: "LEER (sofort nachbestellen)",
lowSection: "WIRD KNAPP (bald nachbestellen)",
},
common: {
pill: "Tablette",
+145 -44
View File
@@ -1,9 +1,11 @@
import { FastifyInstance } from "fastify";
import nodemailer from "nodemailer";
import { updateReminderSentTime } from "../services/reminder-scheduler.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
import { getDateLocale, type Language } from "../i18n/translations.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;
@@ -38,6 +40,21 @@ type ReminderEmailBody = {
};
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<number> {
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;
@@ -191,24 +208,21 @@ Sent from MedAssist-ng Medication Planner`;
return reply.status(400).send({ error: "Missing low stock data" });
}
// Load user settings if authenticated, otherwise use defaults
let notificationSettings = {
emailEnabled: true,
shoutrrrEnabled: false,
shoutrrrUrl: "",
// 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 reminderAuthUser = request.user as unknown as AuthUser | null;
if (reminderAuthUser?.id) {
const userSettings = await loadUserSettings(reminderAuthUser.id);
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;
@@ -219,31 +233,79 @@ Sent from MedAssist-ng Medication Planner`;
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
// Build HTML table with horizontal scroll for mobile
const tableRows = lowStock
.map(
(row) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
// 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 = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
</p>
</div>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
</p>
</div>`;
} else if (emptyMeds.length > 0) {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
</p>
</div>`;
} else {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
</p>
</div>`;
}
// Build table rows with status indicator
const buildTableRow = (row: LowStockItem) => {
const isEmpty = row.medsLeft <= 0;
const statusIcon = isEmpty ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
</tr>
`
)
.join("");
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
</tr>`;
};
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 = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist-ng - Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${descriptionText}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
</p>
</div>
${alertHtml}
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
@@ -267,11 +329,25 @@ Sent from MedAssist-ng Medication Planner`;
</div>
`;
// 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
The following medications are running low:
${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}
${plainTextContent}
---
Sent from MedAssist-ng Medication Planner`;
@@ -290,7 +366,7 @@ Sent from MedAssist-ng Medication Planner`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `⚠️ MedAssist-ng - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
subject: `MedAssist-ng - ${subjectText}`,
text: plainText,
html,
});
@@ -305,10 +381,31 @@ Sent from MedAssist-ng Medication Planner`;
// 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");
// 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);
@@ -325,7 +422,11 @@ Sent from MedAssist-ng Medication Planner`;
// Update the reminder state to record this notification was sent
if (results.email || results.push) {
updateReminderSentTime();
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
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js";
import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
type Blister = { usage: number; every: number; start: string };
@@ -380,6 +380,9 @@ async function checkAndSendIntakeRemindersForUser(
// Update global reminder state for UI display
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
updateReminderSentTime("intake", channel);
// Also update user settings in database so frontend can display the info
await updateUserReminderSentTime(settings.userId, "intake", channel);
}
}
+49 -8
View File
@@ -1,7 +1,7 @@
import nodemailer from "nodemailer";
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { medications, users } from "../db/schema.js";
import { medications, users, userSettings } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
@@ -172,6 +172,22 @@ export function updateReminderSentTime(type: "stock" | "intake" = "stock", chann
});
}
// Update user settings in database when reminder is sent
export async function updateUserReminderSentTime(
userId: number,
type: "stock" | "intake" = "stock",
channel: "email" | "push" | "both" = "email"
): Promise<void> {
const now = new Date().toISOString();
await db.update(userSettings)
.set({
lastAutoEmailSent: now,
lastNotificationType: type,
lastNotificationChannel: channel,
})
.where(eq(userSettings.userId, userId));
}
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
try {
const usage = JSON.parse(row.usageJson) as number[];
@@ -406,17 +422,39 @@ async function checkAndSendReminderForUser(
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
const title = allLowStock.length === 1
? tr.push.stockTitle
: t(tr.push.stockTitleMultiple, { count: allLowStock.length });
let message = allLowStock
.map((m) => `${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`)
.join("\n");
// Separate empty from low stock medications
const emptyMeds = allLowStock.filter(m => m.medsLeft <= 0);
const lowMeds = allLowStock.filter(m => m.medsLeft > 0);
// Build clear title
const titleParts: string[] = [];
if (emptyMeds.length > 0) {
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
}
if (lowMeds.length > 0) {
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`);
}
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`;
// Build clear message with sections
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`);
emptyMeds.forEach(m => messageParts.push(`${m.name}`));
}
if (lowMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
lowMeds.forEach(m => messageParts.push(`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`));
}
if (settings.repeatDailyReminders) {
message += `\n\n${tr.push.repeatDailyNote}`;
messageParts.push("");
messageParts.push(tr.push.repeatDailyNote);
}
const message = messageParts.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
@@ -438,6 +476,9 @@ async function checkAndSendReminderForUser(
lastNotificationType: "stock",
lastNotificationChannel: channel,
});
// Also update user settings in database so frontend can display the info
await updateUserReminderSentTime(settings.userId, "stock", channel);
}
}