From 78ee668c8b02c191a9c5a135140d43ce56e74d49 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 28 Dec 2025 14:42:51 +0100 Subject: [PATCH] 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. --- backend/src/i18n/translations.ts | 15 ++ backend/src/routes/planner.ts | 189 +++++++++++++---- .../src/services/intake-reminder-scheduler.ts | 5 +- backend/src/services/reminder-scheduler.ts | 57 ++++- frontend/src/App.tsx | 195 ++++++++++++++---- frontend/src/i18n/de.json | 7 + frontend/src/i18n/en.json | 7 + frontend/src/styles.css | 46 ++++- 8 files changed, 424 insertions(+), 97 deletions(-) diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts index e55b796..b960e77 100644 --- a/backend/src/i18n/translations.ts +++ b/backend/src/i18n/translations.ts @@ -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 = { 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 = { 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", diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index e145c1a..df1270c 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -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 { + 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) => ` - - ${row.name} - ${row.medsLeft} + // 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 = ` +
+

+ 🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately! +

+
+
+

+ ⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon +

+
`; + } else if (emptyMeds.length > 0) { + alertHtml = ` +
+

+ 🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately! +

+
`; + } else { + alertHtml = ` +
+

+ ⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon +

+
`; + } + + // Build table rows with status indicator + const buildTableRow = (row: LowStockItem) => { + const isEmpty = row.medsLeft <= 0; + const statusIcon = isEmpty ? "🚨" : "⚠️"; + const rowBg = isEmpty ? "#fef2f2" : "white"; + return ` + + ${statusIcon} ${row.name} + ${row.medsLeft} ${row.daysLeft ?? 0} - ${row.depletionDate ?? "-"} - - ` - ) - .join(""); + ${isEmpty ? "NOW" : (row.depletionDate ?? "-")} + `; + }; + + 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 = `
-

⚠️ MedAssist-ng - Reorder Reminder

-

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

+

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder

+

${descriptionText}

-
-

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

-
+ ${alertHtml}
@@ -267,11 +329,25 @@ Sent from MedAssist-ng Medication Planner`; `; + // 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 diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 7b72759..8715b92 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -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); } } diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 142bec1..f5fb277 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -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 { + 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); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3bb5be4..5b95773 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -71,6 +71,21 @@ const defaultBlister = (): FormBlister => { const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] }); +// Field validation limits (must match backend) +const FIELD_LIMITS = { + name: { min: 1, max: 100 }, + genericName: { max: 100 }, + takenBy: { max: 100 }, + notes: { max: 2000 } +} as const; + +type FieldErrors = { + name?: string; + genericName?: string; + takenBy?: string; + notes?: string; +}; + const todayIso = () => new Date().toISOString(); const plusDaysIso = (days: number) => { const d = new Date(); @@ -194,11 +209,39 @@ function AppContent() { const [editingId, setEditingId] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [form, setForm] = useState(defaultForm()); + const [fieldErrors, setFieldErrors] = useState({}); const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); + // Validate form fields + const validateField = (field: keyof FieldErrors, value: string): string | undefined => { + const limits = FIELD_LIMITS[field]; + if (field === 'name' && (!value || value.trim().length === 0)) { + return t('common.validation.required'); + } + if ('max' in limits && value.length > limits.max) { + return t('common.validation.maxLength', { max: limits.max, current: value.length }); + } + return undefined; + }; + + // Check if form has any errors + const hasValidationErrors = useMemo(() => { + return Object.values(fieldErrors).some(error => error !== undefined); + }, [fieldErrors]); + + // Validate all fields when form changes + useEffect(() => { + const errors: FieldErrors = {}; + (['name', 'genericName', 'takenBy', 'notes'] as const).forEach(field => { + const error = validateField(field, form[field]); + if (error) errors[field] = error; + }); + setFieldErrors(errors); + }, [form.name, form.genericName, form.takenBy, form.notes, t]); + // Load user-specific planner data when user changes useEffect(() => { if (typeof window !== "undefined" && user?.id) { @@ -1434,17 +1477,36 @@ function AppContent() {

{editingId ? t('form.editEntry') : t('form.newEntry')}

-