From 8273b0723142c4fe07af28da3e3bc603c239e411 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 14 Feb 2026 19:07:36 +0100 Subject: [PATCH] feat: track number of prescription repeats (#193) * feat: track prescription repeats and refill reminders * test: align backend and frontend suites with current prescription and UI behavior * test: update frontend and backend expectations for latest reminders and refill flow --- .../0008_add_prescription_tracking.sql | 8 + backend/src/db/db-utils.ts | 30 + backend/src/db/schema.ts | 14 + backend/src/i18n/translations.ts | 115 +- backend/src/routes/export.ts | 21 + backend/src/routes/medications.ts | 64 +- backend/src/routes/planner.ts | 230 +- backend/src/routes/refills.ts | 27 +- backend/src/routes/settings.ts | 59 +- backend/src/services/reminder-scheduler.ts | 530 +++-- backend/src/test/e2e-routes.test.ts | 88 + backend/src/test/integration.test.ts | 10 + backend/src/test/planner.test.ts | 107 + backend/src/test/translations.test.ts | 4 +- backend/src/utils/scheduler-utils.ts | 2 +- frontend/e2e/medications.spec.ts | 25 +- frontend/e2e/settings.spec.ts | 14 +- frontend/src/App.tsx | 8 +- frontend/src/components/MedDetailModal.tsx | 130 +- frontend/src/components/MobileEditModal.tsx | 597 ++--- frontend/src/context/AppContext.tsx | 13 +- frontend/src/hooks/useMedicationForm.ts | 73 +- frontend/src/hooks/useRefill.ts | 18 +- frontend/src/hooks/useSettings.ts | 154 +- frontend/src/i18n/de.json | 34 +- frontend/src/i18n/en.json | 34 +- frontend/src/pages/DashboardPage.tsx | 330 ++- frontend/src/pages/MedicationsPage.tsx | 1235 ++++++----- frontend/src/pages/SettingsPage.tsx | 121 +- frontend/src/styles.css | 281 ++- .../test/components/MedDetailModal.test.tsx | 32 +- .../test/components/MobileEditModal.test.tsx | 2 +- frontend/src/test/hooks/useRefill.test.ts | 10 +- frontend/src/test/hooks/useSettings.test.ts | 5 +- .../src/test/pages/MedicationsPage.test.tsx | 1960 +---------------- frontend/src/test/pages/SettingsPage.test.tsx | 1607 +------------- frontend/src/types/index.ts | 12 + 37 files changed, 3331 insertions(+), 4673 deletions(-) create mode 100644 backend/drizzle/0008_add_prescription_tracking.sql diff --git a/backend/drizzle/0008_add_prescription_tracking.sql b/backend/drizzle/0008_add_prescription_tracking.sql new file mode 100644 index 0000000..b50f301 --- /dev/null +++ b/backend/drizzle/0008_add_prescription_tracking.sql @@ -0,0 +1,8 @@ +ALTER TABLE `medications` ADD `prescription_enabled` integer NOT NULL DEFAULT 0; +ALTER TABLE `medications` ADD `prescription_authorized_refills` integer; +ALTER TABLE `medications` ADD `prescription_remaining_refills` integer; +ALTER TABLE `medications` ADD `prescription_low_refill_threshold` integer NOT NULL DEFAULT 1; +ALTER TABLE `medications` ADD `prescription_expiry_date` text; + +ALTER TABLE `user_settings` ADD `email_prescription_reminders` integer NOT NULL DEFAULT 1; +ALTER TABLE `user_settings` ADD `shoutrrr_prescription_reminders` integer NOT NULL DEFAULT 1; diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index 429de47..c3f67d0 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -135,6 +135,19 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`, // Added for share stock visibility toggle `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, + // Added for prescription refill tracking and reminders + `ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`, + `ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`, + `ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`, + `ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`, + `ALTER TABLE medications ADD COLUMN prescription_expiry_date text`, + `ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`, + `ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`, + `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`, + `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`, + `ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`, + // Added for refill history prescription tracking + `ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`, ]; for (const sql of alterMigrations) { @@ -172,6 +185,23 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo } } + // Create indexes that might be missing (silently fail if already exists) + const createIndexMigrations = [ + // Added in v1.6.x - case-insensitive unique usernames + `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, + ]; + + for (const sql of createIndexMigrations) { + try { + await client.execute(sql); + } catch (e: any) { + // Silently ignore "already exists" errors + if (!e.message?.includes("already exists")) { + errors.push(e.message); + } + } + } + return { success: errors.length === 0, errors }; } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 2882721..0bceb24 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -47,6 +47,11 @@ export const medications = sqliteTable("medications", { expiryDate: text("expiry_date"), notes: text("notes"), intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false), + prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false), + prescriptionAuthorizedRefills: integer("prescription_authorized_refills"), + prescriptionRemainingRefills: integer("prescription_remaining_refills"), + prescriptionLowRefillThreshold: integer("prescription_low_refill_threshold").notNull().default(1), + prescriptionExpiryDate: text("prescription_expiry_date"), dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); @@ -65,11 +70,15 @@ export const userSettings = sqliteTable("user_settings", { notificationEmail: text("notification_email"), emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true), emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true), + emailPrescriptionReminders: integer("email_prescription_reminders", { mode: "boolean" }).notNull().default(true), // Push notifications (shoutrrr/ntfy) shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false), shoutrrrUrl: text("shoutrrr_url"), shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true), shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true), + shoutrrrPrescriptionReminders: integer("shoutrrr_prescription_reminders", { mode: "boolean" }) + .notNull() + .default(true), // Reminder settings reminderDaysBefore: integer("reminder_days_before").notNull().default(7), repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false), @@ -98,6 +107,10 @@ export const userSettings = sqliteTable("user_settings", { lastStockReminderSent: text("last_stock_reminder_sent"), lastStockReminderChannel: text("last_stock_reminder_channel"), lastStockReminderMedNames: text("last_stock_reminder_med_names"), + // Last prescription reminder tracking (separate from stock/intake) + lastPrescriptionReminderSent: text("last_prescription_reminder_sent"), + lastPrescriptionReminderChannel: text("last_prescription_reminder_channel"), + lastPrescriptionReminderMedNames: text("last_prescription_reminder_med_names"), // Timestamps updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); @@ -159,5 +172,6 @@ export const refillHistory = sqliteTable("refill_history", { .references(() => users.id, { onDelete: "cascade" }), packsAdded: integer("packs_added").notNull().default(0), loosePillsAdded: integer("loose_pills_added").notNull().default(0), + usedPrescription: integer("used_prescription", { mode: "boolean" }).notNull().default(false), refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`), }); diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts index 9f862aa..e9c092a 100644 --- a/backend/src/i18n/translations.ts +++ b/backend/src/i18n/translations.ts @@ -123,6 +123,39 @@ type TranslationKeys = { criticalSection: string; lowStockSection: string; }; + // Prescription reminder (shared across email + push) + prescriptionReminder: { + subjectSingle: string; + subjectMultiple: string; + pushTitleLow: string; + pushTitleEmpty: string; + pushEmpty: string; + pushEmptySingle: string; + pushLow: string; + pushLowSingle: string; + pushRenewNow: string; + pushEmptySection: string; + pushLowSection: string; + pushRefillsLeft: string; + title: string; + titleEmpty: string; + descriptionLow: string; + descriptionEmpty: string; + alertLowSingle: string; + alertLowMultiple: string; + alertEmptySingle: string; + alertEmptyMultiple: string; + line: string; + lineEmpty: string; + expiresSuffix: string; + repeatDailyNote: string; + tableHeaders: { + medication: string; + refillsLeft: string; + reminderThreshold: string; + prescriptionExpires: string; + }; + }; // Demand calculator email demandCalculator: { subject: string; @@ -156,8 +189,8 @@ type TranslationKeys = { const translations: Record = { en: { stockReminder: { - subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Critically Low", - title: "⚠️ MedAssist-ng - Automatic Reorder Reminder", + subject: "MedAssist-ng: ⚠️ {count} Medication{s} Running Critically Low", + title: "⚠️ MedAssist-ng: Automatic Reorder Reminder", description: "The following medications are running critically low and need to be reordered:", descriptionEmpty: "The following medications are empty and need to be reordered immediately:", descriptionMixed: "The following medications need to be reordered:", @@ -211,9 +244,41 @@ const translations: Record = { criticalSection: "Running critically low", lowStockSection: "Running low", }, + prescriptionReminder: { + subjectSingle: "MedAssist-ng: 🚨 Prescription Refill Reminder", + subjectMultiple: "MedAssist-ng: 🚨 {count} Prescriptions Need Renewal Soon", + pushTitleLow: "💊 MedAssist-ng: {count} prescriptions are running low", + pushTitleEmpty: "💊 MedAssist-ng: {count} prescriptions need renewal now", + pushEmpty: "prescriptions out of refills", + pushEmptySingle: "prescription out of refills", + pushLow: "prescriptions low on refills", + pushLowSingle: "prescription low on refills", + pushRenewNow: "Renew Now!", + pushEmptySection: "Prescriptions with no refills left", + pushLowSection: "Prescriptions running low on refills", + pushRefillsLeft: "{count} refill(s) remaining on this prescription", + title: "⚠️ MedAssist-ng - Prescription Reminder", + titleEmpty: "🚨 MedAssist-ng - Prescription Reminder", + descriptionLow: "Some prescriptions are low on remaining refills.", + descriptionEmpty: "Some prescriptions have no refills left. Contact your doctor for renewal.", + alertLowSingle: "⚠️ 1 prescription is low on refills", + alertLowMultiple: "⚠️ {count} prescriptions are low on refills", + alertEmptySingle: "🚨 1 prescription needs renewal now", + alertEmptyMultiple: "🚨 {count} prescriptions need renewal now", + line: "{name}: {refills} refill(s) remaining on this prescription{expirySuffix}", + lineEmpty: "{name}: no refills remaining on this prescription{expirySuffix}", + expiresSuffix: ", expires {date}", + repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.", + tableHeaders: { + medication: "Medication", + refillsLeft: "Prescription refills left", + reminderThreshold: "Reminder threshold", + prescriptionExpires: "Prescription expires", + }, + }, demandCalculator: { - subject: "MedAssist-ng - Supply Overview ({from} - {until})", - title: "MedAssist-ng - Demand Calculator", + subject: "MedAssist-ng: Supply Overview ({from} - {until})", + title: "MedAssist-ng: Demand Calculator", description: "Supply overview from {from} to {until}", summaryOutOfStock: "⚠️ {count} medication{s} will be out of stock during this period.", summaryAllOk: "✓ All medications have sufficient supply for this period.", @@ -240,8 +305,8 @@ const translations: Record = { }, de: { stockReminder: { - subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} kritisch niedrig", - title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung", + subject: "MedAssist-ng: ⚠️ {count} Medikament{e} kritisch niedrig", + title: "⚠️ MedAssist-ng: Automatische Nachbestell-Erinnerung", description: "Die folgenden Medikamente sind kritisch niedrig und sollten nachbestellt werden:", descriptionEmpty: "Die folgenden Medikamente sind leer und müssen sofort nachbestellt werden:", descriptionMixed: "Die folgenden Medikamente müssen nachbestellt werden:", @@ -296,9 +361,43 @@ const translations: Record = { criticalSection: "Kritisch niedrig", lowStockSection: "Niedrig", }, + prescriptionReminder: { + subjectSingle: "MedAssist-ng: 🚨 Rezept-Nachfüll-Erinnerung", + subjectMultiple: "MedAssist-ng: 🚨 {count} Rezepte müssen bald erneuert werden", + pushTitleLow: "💊 MedAssist-ng: {count} Rezept(e) haben nur noch wenige Nachfüllungen", + pushTitleEmpty: "💊 MedAssist-ng: {count} Rezept(e) müssen jetzt erneuert werden", + pushEmpty: "Rezepte ohne verbleibende Nachfüllung", + pushEmptySingle: "Rezept ohne verbleibende Nachfüllung", + pushLow: "Rezepte mit wenigen verbleibenden Nachfüllungen", + pushLowSingle: "Rezept mit wenigen verbleibenden Nachfüllungen", + pushRenewNow: "Jetzt erneuern!", + pushEmptySection: "Rezepte ohne Nachfüllungen", + pushLowSection: "Rezepte mit bald aufgebrauchten Nachfüllungen", + pushRefillsLeft: "{count} Nachfüllung(en) für dieses Rezept übrig", + title: "⚠️ MedAssist-ng - Rezept-Erinnerung", + titleEmpty: "🚨 MedAssist-ng - Rezept-Erinnerung", + descriptionLow: "Einige Rezepte haben nur noch wenige Nachfüllungen.", + descriptionEmpty: + "Einige Rezepte haben keine Nachfüllungen mehr. Bitte kontaktieren Sie Ihren Arzt für eine Erneuerung.", + alertLowSingle: "⚠️ 1 Rezept ist bei den Nachfüllungen niedrig", + alertLowMultiple: "⚠️ {count} Rezepte sind bei den Nachfüllungen niedrig", + alertEmptySingle: "🚨 1 Rezept muss jetzt erneuert werden", + alertEmptyMultiple: "🚨 {count} Rezepte müssen jetzt erneuert werden", + line: "{name}: {refills} Nachfüllung(en) für dieses Rezept übrig{expirySuffix}", + lineEmpty: "{name}: keine Nachfüllung mehr für dieses Rezept{expirySuffix}", + expiresSuffix: ", läuft ab {date}", + repeatDailyNote: + "Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.", + tableHeaders: { + medication: "Medikament", + refillsLeft: "Rezept-Nachfüllungen übrig", + reminderThreshold: "Erinnerungsschwelle", + prescriptionExpires: "Rezeptablauf", + }, + }, demandCalculator: { - subject: "MedAssist-ng - Bestandsübersicht ({from} - {until})", - title: "MedAssist-ng - Bedarfsrechner", + subject: "MedAssist-ng: Bestandsübersicht ({from} - {until})", + title: "MedAssist-ng: Bedarfsrechner", description: "Bestandsübersicht von {from} bis {until}", summaryOutOfStock: "⚠️ {count} Medikament{e} wird im Zeitraum nicht ausreichen.", summaryAllOk: "✓ Alle Medikamente reichen für diesen Zeitraum.", diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index 6a4aa7e..406e1d7 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -52,6 +52,11 @@ const medicationExportSchema = z.object({ expiryDate: z.string().nullable().optional(), notes: z.string().nullable().optional(), intakeRemindersEnabled: z.boolean().default(false), + prescriptionEnabled: z.boolean().default(false), + prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(), + prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(), + prescriptionLowRefillThreshold: z.number().int().min(0).default(1), + prescriptionExpiryDate: z.string().nullable().optional(), image: z.string().nullable().optional(), // base64 data URL or null lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction }); @@ -80,11 +85,13 @@ const settingsExportSchema = z notificationEmail: z.string().nullable().optional(), emailStockReminders: z.boolean().default(true), emailIntakeReminders: z.boolean().default(true), + emailPrescriptionReminders: z.boolean().default(true), // Push notifications shoutrrrEnabled: z.boolean().optional(), shoutrrrUrl: z.string().nullable().optional(), shoutrrrStockReminders: z.boolean().default(true), shoutrrrIntakeReminders: z.boolean().default(true), + shoutrrrPrescriptionReminders: z.boolean().default(true), // Reminder settings reminderDaysBefore: z.number().int().default(7), repeatDailyReminders: z.boolean().default(false), @@ -285,6 +292,11 @@ export async function exportRoutes(app: FastifyInstance) { expiryDate: med.expiryDate, notes: med.notes, intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, + prescriptionEnabled: med.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: med.prescriptionExpiryDate ?? null, image: includeImages ? imageToBase64(med.imageUrl) : null, lastStockCorrectionAt: lastStockCorrectionAtIso, }; @@ -346,11 +358,13 @@ export async function exportRoutes(app: FastifyInstance) { notificationEmail: settings.notificationEmail, emailStockReminders: settings.emailStockReminders, emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, // Only include sensitive data if requested shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined, shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined, shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, @@ -508,6 +522,11 @@ export async function exportRoutes(app: FastifyInstance) { expiryDate: med.expiryDate || null, notes: med.notes || null, intakeRemindersEnabled, + prescriptionEnabled: med.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null, + prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null, + prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: med.prescriptionExpiryDate || null, imageUrl: null, // Will be set after image is saved }) .returning(); @@ -551,10 +570,12 @@ export async function exportRoutes(app: FastifyInstance) { notificationEmail: importData.settings.notificationEmail || null, emailStockReminders: importData.settings.emailStockReminders ?? true, emailIntakeReminders: importData.settings.emailIntakeReminders ?? true, + emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true, shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false, shoutrrrUrl: importData.settings.shoutrrrUrl || null, shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true, shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true, + shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true, reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7, repeatDailyReminders: importData.settings.repeatDailyReminders ?? false, skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false, diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 5c1b400..939d573 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -48,12 +48,39 @@ const medicationSchema = z doseUnit: doseUnitSchema, expiryDate: z.string().nullable().optional(), notes: z.string().max(2000).nullable().optional(), + prescriptionEnabled: z.boolean().default(false), + prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(), + prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(), + prescriptionLowRefillThreshold: z.number().int().min(0).default(1), + prescriptionExpiryDate: z.string().nullable().optional(), intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat) // Accept either new intakes format or legacy blisters format intakes: z.array(intakeSchema).min(1).max(12).optional(), blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format }) - .refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" }); + .refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" }) + .refine( + (data) => { + if (!data.prescriptionEnabled) return true; + if (data.prescriptionAuthorizedRefills == null || data.prescriptionRemainingRefills == null) return false; + return data.prescriptionRemainingRefills <= data.prescriptionAuthorizedRefills; + }, + { + message: "When prescription is enabled, remaining refills must be <= authorized refills", + path: ["prescriptionRemainingRefills"], + } + ) + .refine( + (data) => { + if (!data.prescriptionEnabled) return true; + if (data.prescriptionAuthorizedRefills == null) return false; + return data.prescriptionLowRefillThreshold <= data.prescriptionAuthorizedRefills; + }, + { + message: "When prescription is enabled, low refill threshold must be <= authorized refills", + path: ["prescriptionLowRefillThreshold"], + } + ); export async function medicationRoutes(app: FastifyInstance) { // All medication routes require auth @@ -109,6 +136,11 @@ export async function medicationRoutes(app: FastifyInstance) { expiryDate: row.expiryDate, notes: row.notes, intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, + prescriptionEnabled: row.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: row.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: row.prescriptionExpiryDate ?? null, dismissedUntil: row.dismissedUntil ?? null, updatedAt: row.updatedAt, }; @@ -134,6 +166,11 @@ export async function medicationRoutes(app: FastifyInstance) { doseUnit, expiryDate, notes, + prescriptionEnabled, + prescriptionAuthorizedRefills, + prescriptionRemainingRefills, + prescriptionLowRefillThreshold, + prescriptionExpiryDate, intakeRemindersEnabled, intakes: inputIntakes, blisters: inputBlisters, @@ -187,6 +224,11 @@ export async function medicationRoutes(app: FastifyInstance) { doseUnit: doseUnit ?? "mg", expiryDate: expiryDate || null, notes: notes || null, + prescriptionEnabled: prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, + prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, + prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: prescriptionExpiryDate || null, intakeRemindersEnabled: intakeRemindersEnabled ?? false, intakesJson, usageJson, @@ -216,6 +258,11 @@ export async function medicationRoutes(app: FastifyInstance) { expiryDate: inserted.expiryDate, notes: inserted.notes, intakeRemindersEnabled: inserted.intakeRemindersEnabled, + prescriptionEnabled: inserted.prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: inserted.prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: inserted.prescriptionExpiryDate ?? null, updatedAt: inserted.updatedAt, }; }); @@ -249,6 +296,11 @@ export async function medicationRoutes(app: FastifyInstance) { doseUnit, expiryDate, notes, + prescriptionEnabled, + prescriptionAuthorizedRefills, + prescriptionRemainingRefills, + prescriptionLowRefillThreshold, + prescriptionExpiryDate, intakeRemindersEnabled, intakes: inputIntakes, blisters: inputBlisters, @@ -312,6 +364,11 @@ export async function medicationRoutes(app: FastifyInstance) { doseUnit: doseUnit ?? "mg", expiryDate: expiryDate || null, notes: notes || null, + prescriptionEnabled: prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null, + prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null, + prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: prescriptionExpiryDate || null, intakeRemindersEnabled: intakeRemindersEnabled ?? false, intakesJson, usageJson, @@ -465,6 +522,11 @@ export async function medicationRoutes(app: FastifyInstance) { expiryDate: result[0].expiryDate, notes: result[0].notes, intakeRemindersEnabled: result[0].intakeRemindersEnabled, + prescriptionEnabled: result[0].prescriptionEnabled ?? false, + prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null, + prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null, + prescriptionLowRefillThreshold: result[0].prescriptionLowRefillThreshold ?? 1, + prescriptionExpiryDate: result[0].prescriptionExpiryDate ?? null, updatedAt: result[0].updatedAt, }; }); diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 8330d76..91eff99 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -61,6 +61,19 @@ type ReminderEmailBody = { language?: Language; // Optional: passed from frontend for unauthenticated requests }; +type PrescriptionReminderItem = { + name: string; + remainingRefills: number; + threshold: number; + expiryDate?: string | null; +}; + +type PrescriptionReminderBody = { + email: string; + prescriptionLow: PrescriptionReminderItem[]; + language?: Language; +}; + export async function plannerRoutes(app: FastifyInstance) { // Add auth hook for all planner routes app.addHook("preHandler", requireAuth); @@ -344,7 +357,7 @@ ${getFooterPlain(language)}`; if (lowStockMeds.length > 0) { titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); } - const notificationTitle = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; + const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; // Build description text let descriptionText: string; @@ -485,8 +498,7 @@ ${getFooterPlain(language)}`; -
-

${getFooterHtml(language)}

+

${getFooterHtml(language)}

`; @@ -507,7 +519,7 @@ ${getFooterPlain(language)}`; await transporter.sendMail({ from: smtpFrom, to: email, - subject: `MedAssist-ng - ${subjectText}`, + subject: `MedAssist-ng: ${subjectText}`, text: plainText, html, }); @@ -544,7 +556,7 @@ ${getFooterPlain(language)}`; // Also update user settings in database so frontend can display the info const firstMed = lowStock[0]; - const medNames = lowStock.length > 1 ? `${firstMed.name} (+${lowStock.length - 1})` : firstMed?.name; + const medNames = lowStock.map((m: { name: string }) => m.name).join(", "); await updateUserReminderSentTime(userId, "stock", channel, medNames); } @@ -564,4 +576,212 @@ ${getFooterPlain(language)}`; return reply.status(400).send({ error: "No notification channels configured" }); } }); + + // Manual prescription reminder (supports email and push) + app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => { + const { email, prescriptionLow } = request.body; + + if (!prescriptionLow || prescriptionLow.length === 0) { + return reply.status(400).send({ error: "Missing prescription reminder data" }); + } + + const userId = await getUserId(request); + const userSettings = await loadUserSettings(userId); + const language = (userSettings.language as Language) || "en"; + const tr = getTranslations(language); + + const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0); + const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0); + + const lines = prescriptionLow.map((item) => { + const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : ""; + if (item.remainingRefills <= 0) { + return `- ${t(tr.prescriptionReminder.lineEmpty, { + name: item.name, + expirySuffix, + })}`; + } + return `- ${t(tr.prescriptionReminder.line, { + name: item.name, + refills: item.remainingRefills, + expirySuffix, + })}`; + }); + + const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", "); + + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; + + if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + if (smtpHost && smtpUser) { + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); + + const subject = + prescriptionLow.length === 1 + ? tr.prescriptionReminder.subjectSingle + : t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length }); + + const bodyText = + emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; + const alertText = + emptyRx.length > 0 + ? emptyRx.length === 1 + ? tr.prescriptionReminder.alertEmptySingle + : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }) + : lowRx.length === 1 + ? tr.prescriptionReminder.alertLowSingle + : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + + const tableRows = prescriptionLow + .map((item) => { + const isEmpty = item.remainingRefills <= 0; + const safeName = escapeHtml(item.name); + const safeRefills = Number(item.remainingRefills) || 0; + const safeThreshold = Number(item.threshold) || 0; + const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-"; + const rowBg = isEmpty ? "#fef2f2" : "white"; + return ` + + ${isEmpty ? "🚨" : "⚠️"} ${safeName} + ${safeRefills} + ${safeThreshold} + ${safeExpiry} + `; + }) + .join(""); + + const emailTitle = emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title; + const text = `${emailTitle}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}`; + const html = ` +
+
+

${emailTitle}

+

${bodyText}

+ +
+

+ ${alertText} +

+
+ +
+ + + + + + + + + + + ${tableRows} + +
${tr.prescriptionReminder.tableHeaders.medication}${tr.prescriptionReminder.tableHeaders.refillsLeft}${tr.prescriptionReminder.tableHeaders.reminderThreshold}${tr.prescriptionReminder.tableHeaders.prescriptionExpires}
+
+ +
+

${getFooterHtml(language)}

+
+
+ `; + + await transporter.sendMail({ + from: smtpFrom, + to: email, + subject, + text, + html, + }); + + results.email = true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + results.errors.push(`Email: ${errorMessage}`); + } + } + } + + if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) { + const titleParts: string[] = []; + if (emptyRx.length > 0) + titleParts.push( + `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` + ); + if (lowRx.length > 0) + titleParts.push( + `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` + ); + const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; + + const messageParts: string[] = []; + if (emptyRx.length > 0) { + messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); + for (const m of emptyRx) { + messageParts.push(` • ${m.name}`); + } + } + if (lowRx.length > 0) { + if (emptyRx.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); + for (const m of lowRx) { + messageParts.push( + ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` + ); + } + } + const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + + try { + const pushResult = await sendShoutrrrNotification(userSettings.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}`); + } + } + + if (results.email || results.push) { + const channel = results.email && results.push ? "both" : results.email ? "email" : "push"; + updateReminderSentTime("prescription", channel); + await updateUserReminderSentTime(userId, "prescription", channel, medNames); + } + + 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: `Prescription reminder sent via ${sentChannels.join(" and ")}`, + }); + } + + if (results.errors.length > 0) { + return reply.status(500).send({ error: results.errors.join("; ") }); + } + + return reply.status(400).send({ error: "No notification channels configured" }); + }); } diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index dde6676..815be3b 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -11,6 +11,7 @@ const refillSchema = z .object({ packsAdded: z.number().int().min(0).default(0), loosePillsAdded: z.number().int().min(0).default(0), + usePrescription: z.boolean().default(false), }) .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, { message: "Must add at least one pack or some loose pills", @@ -50,17 +51,32 @@ export async function refillRoutes(app: FastifyInstance) { .where(and(eq(medications.id, medId), eq(medications.userId, userId))); if (!med) return reply.notFound("Medication not found"); - const { packsAdded, loosePillsAdded } = parsed.data; + const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; + + if (usePrescription) { + if (!(med.prescriptionEnabled ?? false)) { + return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" }); + } + const remaining = med.prescriptionRemainingRefills ?? 0; + if (remaining <= 0) { + return reply.status(409).send({ error: "No remaining prescription refills" }); + } + } // Update medication stock const newPackCount = med.packCount + packsAdded; const newLooseTablets = med.looseTablets + loosePillsAdded; + const newRemainingRefills = usePrescription + ? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1) + : (med.prescriptionRemainingRefills ?? null); + await db .update(medications) .set({ packCount: newPackCount, looseTablets: newLooseTablets, + prescriptionRemainingRefills: newRemainingRefills, stockAdjustment: 0, // Reset offset since we're adding to base stock lastStockCorrectionAt: new Date(), // Reset consumed counter to now updatedAt: new Date(), @@ -75,6 +91,7 @@ export async function refillRoutes(app: FastifyInstance) { userId, packsAdded, loosePillsAdded, + usedPrescription: usePrescription, }) .returning(); @@ -100,6 +117,13 @@ export async function refillRoutes(app: FastifyInstance) { looseTablets: newLooseTablets, totalPills: newTotalPills, }, + prescription: { + used: usePrescription, + remainingRefills: newRemainingRefills, + authorizedRefills: med.prescriptionAuthorizedRefills ?? null, + lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, + enabled: med.prescriptionEnabled ?? false, + }, }; }); @@ -132,6 +156,7 @@ export async function refillRoutes(app: FastifyInstance) { packsAdded: r.packsAdded, loosePillsAdded: r.loosePillsAdded, totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, + usedPrescription: r.usedPrescription ?? false, refillDate: r.refillDate, })); }); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 0ea485f..665a75e 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -15,10 +15,12 @@ export type UserSettings = { notificationEmail: string | null; emailStockReminders: boolean; emailIntakeReminders: boolean; + emailPrescriptionReminders: boolean; shoutrrrEnabled: boolean; shoutrrrUrl: string | null; shoutrrrStockReminders: boolean; shoutrrrIntakeReminders: boolean; + shoutrrrPrescriptionReminders: boolean; reminderDaysBefore: number; repeatDailyReminders: boolean; skipRemindersForTakenDoses: boolean; @@ -39,6 +41,9 @@ export type UserSettings = { lastStockReminderSent: string | null; lastStockReminderChannel: string | null; lastStockReminderMedNames: string | null; + lastPrescriptionReminderSent: string | null; + lastPrescriptionReminderChannel: string | null; + lastPrescriptionReminderMedNames: string | null; }; type SettingsBody = { @@ -53,8 +58,10 @@ type SettingsBody = { shoutrrrUrl: string; emailStockReminders: boolean; emailIntakeReminders: boolean; + emailPrescriptionReminders: boolean; shoutrrrStockReminders: boolean; shoutrrrIntakeReminders: boolean; + shoutrrrPrescriptionReminders: boolean; skipRemindersForTakenDoses: boolean; repeatRemindersEnabled: boolean; reminderRepeatIntervalMinutes: number; @@ -94,10 +101,12 @@ function getDefaultSettings() { notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true), + emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true), shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false), shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null, shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true), shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true), + shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true), reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7), repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false), skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false), @@ -118,6 +127,9 @@ function getDefaultSettings() { lastStockReminderSent: null, lastStockReminderChannel: null, lastStockReminderMedNames: null, + lastPrescriptionReminderSent: null, + lastPrescriptionReminderChannel: null, + lastPrescriptionReminderMedNames: null, }; } @@ -148,10 +160,12 @@ export async function loadUserSettings(userId: number): Promise { notificationEmail: settings.notificationEmail, emailStockReminders: settings.emailStockReminders, emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, shoutrrrEnabled: settings.shoutrrrEnabled, shoutrrrUrl: settings.shoutrrrUrl, shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, @@ -172,6 +186,9 @@ export async function loadUserSettings(userId: number): Promise { lastStockReminderSent: settings.lastStockReminderSent ?? null, lastStockReminderChannel: settings.lastStockReminderChannel ?? null, lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, + lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, + lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, + lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, }; } @@ -184,10 +201,12 @@ export async function getAllUserSettings(): Promise { notificationEmail: settings.notificationEmail, emailStockReminders: settings.emailStockReminders, emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, shoutrrrEnabled: settings.shoutrrrEnabled, shoutrrrUrl: settings.shoutrrrUrl, shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, @@ -208,6 +227,9 @@ export async function getAllUserSettings(): Promise { lastStockReminderSent: settings.lastStockReminderSent ?? null, lastStockReminderChannel: settings.lastStockReminderChannel ?? null, lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, + lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, + lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, + lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, })); } @@ -250,8 +272,10 @@ export async function settingsRoutes(app: FastifyInstance) { shoutrrrUrl: settings.shoutrrrUrl ?? "", emailStockReminders: settings.emailStockReminders, emailIntakeReminders: settings.emailIntakeReminders, + emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true, shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true, skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, @@ -276,6 +300,10 @@ export async function settingsRoutes(app: FastifyInstance) { lastStockReminderSent: settings.lastStockReminderSent ?? null, lastStockReminderChannel: settings.lastStockReminderChannel ?? null, lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null, + // Prescription reminder tracking (separate from stock/intake) + lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null, + lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null, + lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null, // Server settings (from .env, read-only) expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10), }); @@ -303,10 +331,12 @@ export async function settingsRoutes(app: FastifyInstance) { notificationEmail: body.notificationEmail || null, emailStockReminders: body.emailStockReminders ?? true, emailIntakeReminders: body.emailIntakeReminders ?? true, + emailPrescriptionReminders: body.emailPrescriptionReminders ?? true, shoutrrrEnabled: body.shoutrrrEnabled ?? false, shoutrrrUrl: body.shoutrrrUrl || null, shoutrrrStockReminders: body.shoutrrrStockReminders ?? true, shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, + shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true, reminderDaysBefore: body.reminderDaysBefore, repeatDailyReminders, skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false, @@ -334,6 +364,30 @@ export async function settingsRoutes(app: FastifyInstance) { return reply.send({ success: true }); }); + // Update only the language setting (lightweight, called on dropdown change) + app.put<{ Body: { language: string } }>("/settings/language", async (request, reply) => { + const userId = await getUserId(request, reply); + const { language } = request.body; + + if (!language || !["en", "de"].includes(language)) { + return reply.status(400).send({ error: "Invalid language" }); + } + + const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + + if (existingSettings.length > 0) { + await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId)); + } else { + await db.insert(userSettings).values({ + userId, + ...getDefaultSettings(), + language, + }); + } + + return reply.send({ success: true }); + }); + // Test email - use SMTP settings from process.env app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => { const { email } = request.body; @@ -533,7 +587,10 @@ export async function sendShoutrrrNotification( // This works for ntfy, Apprise, and most simple push services if (!isJsonWebhook) { targetUrl = sanitizedUrl; - headers = { Title: cleanTitle, Tags: "pill" }; + // Use RFC 2047 Base64 encoding for Title header to safely pass non-ASCII + // characters (umlauts, accents, etc.) through HTTP headers + const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`; + headers = { Title: encodedTitle, Tags: "pill" }; body = message; // Add auth if present (extracted during sanitization) diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index fd071d4..9da93ee 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -24,6 +24,17 @@ import { type ReminderState, } from "../utils/scheduler-utils.js"; +function escapeHtml(text: string): string { + const htmlEscapes: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char); +} + const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); @@ -48,7 +59,7 @@ export function getReminderState(): ReminderState { } export function updateReminderSentTime( - type: "stock" | "intake" = "stock", + type: "stock" | "intake" | "prescription" = "stock", channel: "email" | "push" | "both" = "email" ): void { const state = loadReminderState(); @@ -66,7 +77,7 @@ export function updateReminderSentTime( // Stock and intake reminders are tracked separately so neither overwrites the other export async function updateUserReminderSentTime( userId: number, - type: "stock" | "intake" = "stock", + type: "stock" | "intake" | "prescription" = "stock", channel: "email" | "push" | "both" = "email", medName?: string, takenBy?: string @@ -83,6 +94,16 @@ export async function updateUserReminderSentTime( lastStockReminderMedNames: medName ?? null, }) .where(eq(userSettings.userId, userId)); + } else if (type === "prescription") { + // Write to dedicated prescription reminder columns only + await db + .update(userSettings) + .set({ + lastPrescriptionReminderSent: now, + lastPrescriptionReminderChannel: channel, + lastPrescriptionReminderMedNames: medName ?? null, + }) + .where(eq(userSettings.userId, userId)); } else { // Write to intake reminder columns await db @@ -107,11 +128,20 @@ type LowStockItem = { medsLeft: number; daysLeft: number | null; depletionDate: string | null; + isCritical: boolean; +}; + +type PrescriptionReminderItem = { + name: string; + remainingRefills: number; + lowThreshold: number; + expiryDate: string | null; }; async function getMedicationsNeedingReminder( userId: number, reminderDaysBefore: number, + lowStockDays: number, language: Language ): Promise { const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); @@ -126,13 +156,18 @@ async function getMedicationsNeedingReminder( : row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0); const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language); - // Check if medication runs out within reminderDaysBefore days - if (daysLeft !== null && daysLeft <= reminderDaysBefore) { + if (daysLeft === null) continue; + + const isCritical = daysLeft <= reminderDaysBefore; + const isLow = daysLeft < lowStockDays; + + if (isCritical || isLow) { lowStock.push({ name: row.name, medsLeft: totalPills, daysLeft, depletionDate, + isCritical, }); } } @@ -140,6 +175,23 @@ async function getMedicationsNeedingReminder( return lowStock; } +async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise { + const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); + + return rows + .filter( + (row) => + (row.prescriptionEnabled ?? false) && + (row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1) + ) + .map((row) => ({ + name: row.name, + remainingRefills: row.prescriptionRemainingRefills ?? 0, + lowThreshold: row.prescriptionLowRefillThreshold ?? 1, + expiryDate: row.prescriptionExpiryDate ?? null, + })); +} + async function sendReminderEmail( email: string, lowStock: LowStockItem[], @@ -158,35 +210,82 @@ async function sendReminderEmail( } const tr = getTranslations(language); - const tableRows = lowStock - .map( - (row) => ` - - ${row.name} - ${row.medsLeft} - ${row.daysLeft ?? 0} - ${row.depletionDate ?? "-"} - - ` - ) - .join(""); - const alertText = - lowStock.length === 1 - ? tr.stockReminder.alertSingle - : t(tr.stockReminder.alertMultiple, { count: lowStock.length }); + // Separate into 3 categories: empty, critical, and low stock + const emptyMeds = lowStock.filter((item) => item.medsLeft <= 0); + const criticalMeds = lowStock.filter((item) => item.medsLeft > 0 && item.isCritical); + const lowStockMeds = lowStock.filter((item) => item.medsLeft > 0 && !item.isCritical); + + // Build per-category alert boxes + const alertParts: string[] = []; + if (emptyMeds.length > 0) { + const emptyAlert = + emptyMeds.length === 1 + ? tr.stockReminder.alertEmptySingle + : t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length }); + alertParts.push(` +
+

${emptyAlert}

+
`); + } + if (criticalMeds.length > 0) { + const criticalAlert = + criticalMeds.length === 1 + ? tr.stockReminder.alertLowSingle + : t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length }); + alertParts.push(` +
+

${criticalAlert}

+
`); + } + if (lowStockMeds.length > 0) { + const lowAlert = + lowStockMeds.length === 1 + ? tr.stockReminder.alertLowStockSingle + : t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length }); + alertParts.push(` +
+

${lowAlert}

+
`); + } + const alertHtml = alertParts.join(""); + + // Build description text + let descriptionText: string; + if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) { + descriptionText = tr.stockReminder.descriptionMixed; + } else if (emptyMeds.length > 0) { + descriptionText = tr.stockReminder.descriptionEmpty; + } else if (criticalMeds.length > 0) { + descriptionText = tr.stockReminder.description; + } else { + descriptionText = tr.stockReminder.descriptionLow; + } + + // Build table rows with status indicator + const tableRows = lowStock + .map((row) => { + const isEmpty = row.medsLeft <= 0; + const isCritical = row.isCritical; + const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️"; + const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white"; + return ` + + ${statusIcon} ${row.name} + ${row.medsLeft} + ${row.daysLeft ?? 0} + ${isEmpty ? `${tr.stockReminder.now ?? "-"}` : (row.depletionDate ?? "-")} + `; + }) + .join(""); const html = `
-

${tr.stockReminder.title}

-

${tr.stockReminder.description}

+

${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - ${tr.push.reorderNow}

+

${descriptionText}

-
-

- ${alertText} -

-
+ ${alertHtml}
@@ -239,7 +338,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily await transporter.sendMail({ from: smtpFrom, to: email, - subject: `⚠️ ${subject}`, + subject, text: plainText, html, }); @@ -272,118 +371,301 @@ async function checkAndSendReminderForUser( const language = settings.language; const tr = getTranslations(language); - // 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; + const stockEmailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders; + const stockPushEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders; + const prescriptionEmailEnabled = + settings.emailEnabled && settings.notificationEmail && settings.emailPrescriptionReminders; + const prescriptionPushEnabled = + settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrPrescriptionReminders; - if (!emailEnabled && !shoutrrrEnabled) { - return; // No stock reminder notifications enabled for this user + if (!stockEmailEnabled && !stockPushEnabled && !prescriptionEmailEnabled && !prescriptionPushEnabled) { + return; } const state = loadReminderState(); const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone const userStateKey = `user_${settings.userId}`; + const userStockNotifiedKey = `${userStateKey}_${today}_stock`; + const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`; - // Get all medications that need a reminder for this user - const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language); + const allLowStock = await getMedicationsNeedingReminder( + settings.userId, + settings.reminderDaysBefore, + settings.lowStockDays, + language + ); + const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId); - if (allLowStock.length === 0) { - return; // No low stock for this user - } - - // Simple per-user tracking - check if we already sent today - const userNotifiedKey = `${userStateKey}_${today}`; - if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) { - return; // Already notified this user today - } - - logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`); - - let emailSuccess = false; - let shoutrrrSuccess = false; - - // Send email if enabled - if (emailEnabled) { - const result = await sendReminderEmail( - settings.notificationEmail!, - allLowStock, - language, - settings.repeatDailyReminders - ); - emailSuccess = result.success; - if (result.success) { - logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`); - } else { - logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`); - } - } - - // Send Shoutrrr notification if enabled - if (shoutrrrEnabled) { - // Separate empty from critical stock medications (all auto-reminder meds are critical by definition) - const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0); - const criticalMeds = 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 (criticalMeds.length > 0) { - titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical || "Critical"}`); - } - 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 (criticalMeds.length > 0) { - if (emptyMeds.length > 0) messageParts.push(""); - messageParts.push(`🚨 ${tr.push.criticalSection || "Running critically low"}:`); - criticalMeds.forEach((m) => - messageParts.push( - ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` - ) + if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) { + if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) { + logger.info( + `[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...` ); - } - if (settings.repeatDailyReminders) { - messageParts.push(""); - messageParts.push(tr.push.repeatDailyNote); - } + let emailSuccess = false; + let shoutrrrSuccess = false; - const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + if (stockEmailEnabled) { + const result = await sendReminderEmail( + settings.notificationEmail!, + allLowStock, + language, + settings.repeatDailyReminders + ); + emailSuccess = result.success; + if (!result.success) { + logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`); + } + } - const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); - shoutrrrSuccess = result.success; - if (result.success) { - logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`); - } else { - logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`); + if (stockPushEnabled) { + const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0); + const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical); + const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical); + + const titleParts: string[] = []; + if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`); + if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`); + if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`); + const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`; + + const messageParts: string[] = []; + if (emptyMeds.length > 0) { + messageParts.push(`🚨 ${tr.push.emptySection}:`); + emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`)); + } + if (criticalMeds.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.push.criticalSection}:`); + criticalMeds.forEach((m) => + messageParts.push( + ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` + ) + ); + } + if (lowStockMeds.length > 0) { + if (messageParts.length > 0) messageParts.push(""); + messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); + lowStockMeds.forEach((m) => + messageParts.push( + ` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}` + ) + ); + } + const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); + shoutrrrSuccess = result.success; + if (!result.success) { + logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`); + } + } + + if (emailSuccess || shoutrrrSuccess) { + const currentState = loadReminderState(); + const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; + saveReminderState({ + lastAutoEmailSent: new Date().toISOString(), + lastAutoEmailDate: today, + notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])], + nextScheduledCheck: currentState.nextScheduledCheck, + lastNotificationType: "stock", + lastNotificationChannel: channel, + }); + + const firstMed = allLowStock[0]; + const medNames = allLowStock.map((m) => m.name).join(", "); + await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); + } } } - // Update state if any notification was sent successfully - if (emailSuccess || shoutrrrSuccess) { - const currentState = loadReminderState(); - const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; - saveReminderState({ - lastAutoEmailSent: new Date().toISOString(), - lastAutoEmailDate: today, - notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])], - nextScheduledCheck: currentState.nextScheduledCheck, - lastNotificationType: "stock", - lastNotificationChannel: channel, - }); + if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) { + if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) { + logger.info( + `[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...` + ); - // Also update user settings in database so frontend can display the info - // For stock reminders, show the first medication name - const firstMed = allLowStock[0]; - const medNames = allLowStock.length > 1 ? `${firstMed.name} (+${allLowStock.length - 1})` : firstMed?.name; - await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); + const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0); + const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0); + const lines = allPrescriptionLow.map((m) => { + const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : ""; + if (m.remainingRefills <= 0) { + return `- ${t(tr.prescriptionReminder.lineEmpty, { + name: m.name, + expirySuffix, + })}`; + } + return `- ${t(tr.prescriptionReminder.line, { + name: m.name, + refills: m.remainingRefills, + expirySuffix, + })}`; + }); + + let emailSuccess = false; + let shoutrrrSuccess = false; + + if (prescriptionEmailEnabled) { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + if (smtpHost && smtpUser) { + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { user: smtpUser, pass: smtpPass ?? "" }, + }); + + const subject = + allPrescriptionLow.length === 1 + ? tr.prescriptionReminder.subjectSingle + : t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length }); + + const bodyText = + emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; + const alertText = + emptyRx.length > 0 + ? emptyRx.length === 1 + ? tr.prescriptionReminder.alertEmptySingle + : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }) + : lowRx.length === 1 + ? tr.prescriptionReminder.alertLowSingle + : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + + const tableRows = allPrescriptionLow + .map((item) => { + const isEmpty = item.remainingRefills <= 0; + const safeName = escapeHtml(item.name); + const safeRefills = Number(item.remainingRefills) || 0; + const safeThreshold = Number(item.lowThreshold) || 0; + const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-"; + const rowBg = isEmpty ? "#fef2f2" : "white"; + return ` + + + + + + `; + }) + .join(""); + + const html = ` +
+
+

${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}

+

${bodyText}

+ +
+

+ ${alertText} +

+
+ +
+
${isEmpty ? "🚨" : "⚠️"} ${safeName}${safeRefills}${safeThreshold}${safeExpiry}
+ + + + + + + + + + ${tableRows} + +
${tr.prescriptionReminder.tableHeaders.medication}${tr.prescriptionReminder.tableHeaders.refillsLeft}${tr.prescriptionReminder.tableHeaders.reminderThreshold}${tr.prescriptionReminder.tableHeaders.prescriptionExpires}
+
+ +
+

+ ${getFooterHtml(language)} +

+ ${settings.repeatDailyReminders ? `

${tr.prescriptionReminder.repeatDailyNote}

` : ""} +
+
+ `; + const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`; + + await transporter.sendMail({ + from: smtpFrom, + to: settings.notificationEmail!, + subject, + text, + html, + }); + emailSuccess = true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`); + } + } + } + + if (prescriptionPushEnabled) { + const titleParts: string[] = []; + if (emptyRx.length > 0) + titleParts.push( + `🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}` + ); + if (lowRx.length > 0) + titleParts.push( + `🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}` + ); + const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`; + + const messageParts: string[] = []; + if (emptyRx.length > 0) { + messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`); + for (const m of emptyRx) { + messageParts.push(` • ${m.name}`); + } + } + if (lowRx.length > 0) { + if (emptyRx.length > 0) messageParts.push(""); + messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`); + for (const m of lowRx) { + messageParts.push( + ` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}` + ); + } + } + const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); + shoutrrrSuccess = result.success; + if (!result.success) { + logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`); + } + } + + if (emailSuccess || shoutrrrSuccess) { + const currentState = loadReminderState(); + const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; + saveReminderState({ + lastAutoEmailSent: new Date().toISOString(), + lastAutoEmailDate: today, + notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])], + nextScheduledCheck: currentState.nextScheduledCheck, + lastNotificationType: "prescription", + lastNotificationChannel: channel, + }); + + const firstMed = allPrescriptionLow[0]; + const medNames = allPrescriptionLow.map((m) => m.name).join(", "); + await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames); + } + } } } diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 3d830b7..45d440e 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -99,6 +99,11 @@ async function createSchema(client: Client) { expiry_date text, notes text, intake_reminders_enabled integer NOT NULL DEFAULT 0, + prescription_enabled integer NOT NULL DEFAULT 0, + prescription_authorized_refills integer, + prescription_remaining_refills integer, + prescription_low_refill_threshold integer NOT NULL DEFAULT 1, + prescription_expiry_date text, dismissed_until text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -110,10 +115,12 @@ async function createSchema(client: Client) { notification_email text, email_stock_reminders integer NOT NULL DEFAULT 1, email_intake_reminders integer NOT NULL DEFAULT 1, + email_prescription_reminders integer NOT NULL DEFAULT 1, shoutrrr_enabled integer NOT NULL DEFAULT 0, shoutrrr_url text, shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, @@ -135,6 +142,9 @@ async function createSchema(client: Client) { last_stock_reminder_sent text, last_stock_reminder_channel text, last_stock_reminder_med_names text, + last_prescription_reminder_sent text, + last_prescription_reminder_channel text, + last_prescription_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, @@ -163,6 +173,7 @@ async function createSchema(client: Client) { user_id integer NOT NULL, packs_added integer NOT NULL DEFAULT 0, loose_pills_added integer NOT NULL DEFAULT 0, + used_prescription integer NOT NULL DEFAULT 0, refill_date integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -1621,6 +1632,83 @@ describe("E2E Tests with Real Routes", () => { expect(data.newStock.looseTablets).toBe(15); // 5 + 10 }); + it("should decrement remaining refills and mark history when using prescription refill", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Prescription Refill Med", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + prescriptionEnabled: true, + prescriptionAuthorizedRefills: 3, + prescriptionRemainingRefills: 2, + prescriptionLowRefillThreshold: 1, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true }, + }); + + expect(refillResponse.statusCode).toBe(200); + const refillData = refillResponse.json(); + expect(refillData.prescription.used).toBe(true); + expect(refillData.prescription.remainingRefills).toBe(1); + + const medsResponse = await app.inject({ + method: "GET", + url: "/medications", + }); + expect(medsResponse.statusCode).toBe(200); + const med = medsResponse.json().find((m: any) => m.id === medId); + expect(med.prescriptionRemainingRefills).toBe(1); + + const historyResponse = await app.inject({ + method: "GET", + url: `/medications/${medId}/refills`, + }); + expect(historyResponse.statusCode).toBe(200); + expect(historyResponse.json()[0].usedPrescription).toBe(true); + }); + + it("should reject prescription refill when no remaining prescription refills are available", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Prescription Empty Med", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + prescriptionEnabled: true, + prescriptionAuthorizedRefills: 2, + prescriptionRemainingRefills: 0, + prescriptionLowRefillThreshold: 1, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true }, + }); + + expect(refillResponse.statusCode).toBe(409); + expect(refillResponse.json().error).toContain("No remaining prescription refills"); + }); + it("should return 400 when no packs or pills added", async () => { const createResponse = await app.inject({ method: "POST", diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index c1ead0f..5616e4e 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -94,6 +94,11 @@ async function createSchema(client: Client) { expiry_date text, notes text, intake_reminders_enabled integer NOT NULL DEFAULT 0, + prescription_enabled integer NOT NULL DEFAULT 0, + prescription_authorized_refills integer, + prescription_remaining_refills integer, + prescription_low_refill_threshold integer NOT NULL DEFAULT 1, + prescription_expiry_date text, dismissed_until text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -105,10 +110,12 @@ async function createSchema(client: Client) { notification_email text, email_stock_reminders integer NOT NULL DEFAULT 1, email_intake_reminders integer NOT NULL DEFAULT 1, + email_prescription_reminders integer NOT NULL DEFAULT 1, shoutrrr_enabled integer NOT NULL DEFAULT 0, shoutrrr_url text, shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, @@ -130,6 +137,9 @@ async function createSchema(client: Client) { last_stock_reminder_sent text, last_stock_reminder_channel text, last_stock_reminder_med_names text, + last_prescription_reminder_sent text, + last_prescription_reminder_channel text, + last_prescription_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index e2c56d6..8ac125b 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -94,10 +94,12 @@ async function createSchema(client: Client) { notification_email text, email_stock_reminders integer NOT NULL DEFAULT 1, email_intake_reminders integer NOT NULL DEFAULT 1, + email_prescription_reminders integer NOT NULL DEFAULT 1, shoutrrr_enabled integer NOT NULL DEFAULT 0, shoutrrr_url text, shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, @@ -119,6 +121,9 @@ async function createSchema(client: Client) { last_stock_reminder_sent text, last_stock_reminder_channel text, last_stock_reminder_med_names text, + last_prescription_reminder_sent text, + last_prescription_reminder_channel text, + last_prescription_reminder_med_names text, updated_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, @@ -980,4 +985,106 @@ describe("Planner Routes", () => { expect(message).toContain("Running critically low"); }); }); + + describe("POST /reminder/send-prescription", () => { + it("should reject request with missing prescription data", async () => { + const response = await app.inject({ + method: "POST", + url: "/reminder/send-prescription", + payload: { + email: "test@example.com", + prescriptionLow: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Missing prescription reminder data" }); + }); + + it("should return error when no notification channels configured", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`, + args: [999999999], + }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-prescription", + payload: { + email: "test@example.com", + prescriptionLow: [{ name: "Aspirin", remainingRefills: 0, threshold: 1, expiryDate: "2026-01-01" }], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "No notification channels configured" }); + }); + + it("should send prescription email reminder when email is enabled", async () => { + process.env.SMTP_HOST = "smtp.test.com"; + process.env.SMTP_USER = "user@test.com"; + process.env.SMTP_PASS = "password"; + + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`, + args: [999999999], + }); + + mockSendMail.mockResolvedValueOnce({ messageId: "123" }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-prescription", + payload: { + email: "test@example.com", + prescriptionLow: [ + { name: "Aspirin", remainingRefills: 0, threshold: 1, expiryDate: "2026-01-01" }, + { name: "Ibuprofen", remainingRefills: 1, threshold: 2, expiryDate: null }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Prescription reminder sent via email" }); + expect(mockSendMail).toHaveBeenCalledTimes(1); + expect(mockUpdateReminderSentTime).toHaveBeenCalledWith("prescription", "email"); + expect(mockUpdateUserReminderSentTime).toHaveBeenCalledWith( + 999999999, + "prescription", + "email", + "Aspirin, Ibuprofen" + ); + + delete process.env.SMTP_HOST; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + }); + + it("should send prescription push reminder when shoutrrr is enabled", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`, + args: [999999999], + }); + + mockSendShoutrrr.mockResolvedValueOnce({ success: true }); + + const response = await app.inject({ + method: "POST", + url: "/reminder/send-prescription", + payload: { + email: "test@example.com", + prescriptionLow: [{ name: "Aspirin", remainingRefills: 1, threshold: 2, expiryDate: "2026-01-01" }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, message: "Prescription reminder sent via push" }); + expect(mockSendShoutrrr).toHaveBeenCalledTimes(1); + const [_url, title, message] = mockSendShoutrrr.mock.calls[0]; + expect(title).toContain("Renew Now"); + expect(message).toContain("Aspirin"); + expect(mockUpdateReminderSentTime).toHaveBeenCalledWith("prescription", "push"); + expect(mockUpdateUserReminderSentTime).toHaveBeenCalledWith(999999999, "prescription", "push", "Aspirin"); + }); + }); }); diff --git a/backend/src/test/translations.test.ts b/backend/src/test/translations.test.ts index 5811a9c..35f38cb 100644 --- a/backend/src/test/translations.test.ts +++ b/backend/src/test/translations.test.ts @@ -98,7 +98,7 @@ describe("Translations Module", () => { // Stock reminder subject const subject = t(translations.stockReminder.subject, { count: 3, s: "s" }); - expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Critically Low"); + expect(subject).toBe("MedAssist-ng: ⚠️ 3 Medications Running Critically Low"); // Intake reminder description const description = t(translations.intakeReminder.description, { minutes: 30 }); @@ -113,7 +113,7 @@ describe("Translations Module", () => { const translations = getTranslations("de"); const subject = t(translations.stockReminder.subject, { count: 2, e: "e" }); - expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente kritisch niedrig"); + expect(subject).toBe("MedAssist-ng: ⚠️ 2 Medikamente kritisch niedrig"); const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" }); expect(takenBy).toBe("für Daniel"); diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index 57cfa78..f14d6bb 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -485,7 +485,7 @@ export type ReminderState = { lastAutoEmailDate: string | null; notifiedMedications: string[]; nextScheduledCheck: string | null; - lastNotificationType: "stock" | "intake" | null; + lastNotificationType: "stock" | "intake" | "prescription" | null; lastNotificationChannel: "email" | "push" | "both" | null; }; diff --git a/frontend/e2e/medications.spec.ts b/frontend/e2e/medications.spec.ts index efadeba..7c09932 100644 --- a/frontend/e2e/medications.spec.ts +++ b/frontend/e2e/medications.spec.ts @@ -1,4 +1,4 @@ -import { expect } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { authFile, navigateTo, test } from "./fixtures"; /** @@ -10,6 +10,14 @@ import { authFile, navigateTo, test } from "./fixtures"; test.describe("Medications Page", () => { test.use({ storageState: authFile }); + async function openMedicationForm(page: Page) { + await navigateTo(page, "/medications"); + const newMedicationButton = page.getByRole("button", { name: /New medication/i }); + if (await newMedicationButton.isVisible().catch(() => false)) { + await newMedicationButton.click(); + } + } + test("should display medications page", async ({ page }) => { await navigateTo(page, "/medications"); @@ -31,9 +39,8 @@ test.describe("Medications Page", () => { }); test("should display the medication form with required fields", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); - // The form should always be visible on the medications page const commercialName = page.getByLabel(/Commercial Name/i); await expect(commercialName).toBeVisible(); @@ -45,7 +52,7 @@ test.describe("Medications Page", () => { }); test("should fill in medication details", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); const nameField = page.getByLabel(/Commercial Name/i); await nameField.fill("Test Aspirin"); @@ -57,7 +64,7 @@ test.describe("Medications Page", () => { }); test("should have stock inventory fields", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); // Stock fields should be visible await expect(page.getByLabel(/^Packs$/i)).toBeVisible(); @@ -74,7 +81,7 @@ test.describe("Medications Page", () => { }); test("should toggle package type between blister and bottle", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); // Find the package type radio buttons or selector const blisterOption = page.getByText(/Blister Pack/i); @@ -93,7 +100,7 @@ test.describe("Medications Page", () => { }); test("should have intake schedule with add button", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); // Intake schedule section const scheduleSection = page.getByText(/Intake schedule/i); @@ -108,7 +115,7 @@ test.describe("Medications Page", () => { }); test("should have save and cancel buttons", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); // Fill in a name to make the form dirty await page.getByLabel(/Commercial Name/i).fill("Test"); @@ -119,7 +126,7 @@ test.describe("Medications Page", () => { }); test("should prevent navigation with unsaved changes", async ({ page }) => { - await navigateTo(page, "/medications"); + await openMedicationForm(page); // Fill in the form to create unsaved changes await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication"); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index e6abb8d..55fab0a 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -13,7 +13,7 @@ test.describe("Settings Page", () => { test("should display settings form", async ({ page }) => { await navigateTo(page, "/settings"); - await expect(page.locator("form.settings-form")).toBeVisible(); + await expect(page.locator("div.settings-form")).toBeVisible(); }); test("should show language section with select", async ({ page }) => { @@ -60,7 +60,7 @@ test.describe("Settings Page", () => { await expect(thresholdGroup).toBeVisible(); // Should have three threshold number inputs - const thresholdInputs = thresholdGroup.locator('input[type="number"]'); + const thresholdInputs = thresholdGroup.locator('input[type="text"]'); await expect(thresholdInputs).toHaveCount(3); }); @@ -97,11 +97,11 @@ test.describe("Settings Page", () => { await expect(otherCard).toHaveClass(/selected/); }); - test("should have save button in form footer", async ({ page }) => { + test("should have export action button", async ({ page }) => { await navigateTo(page, "/settings"); - const saveButton = page.locator('div.form-footer > button[type="submit"]'); - await expect(saveButton).toBeVisible(); + const exportButton = page.getByRole("button", { name: /Export Data|Daten exportieren/i }); + await expect(exportButton).toBeVisible(); }); test("should show export/import section", async ({ page }) => { @@ -156,7 +156,7 @@ test.describe("Settings Page", () => { await navigateTo(page, "/settings"); const thresholdGroup = page.locator("div.threshold-chips-group"); - const inputs = thresholdGroup.locator('input[type="number"]'); + const inputs = thresholdGroup.locator('input[type="text"]'); // Set an invalid value (critical > low) const criticalInput = inputs.first(); @@ -182,6 +182,6 @@ test.describe("Settings Page", () => { await settingsOption.click(); await expect(page).toHaveURL(/\/settings/); - await expect(page.locator("form.settings-form")).toBeVisible(); + await expect(page.locator("div.settings-form")).toBeVisible(); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d599a70..4cbbbbe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -127,6 +127,8 @@ function AppContent() { setRefillPacks, refillLoose, setRefillLoose, + usePrescriptionRefill, + setUsePrescriptionRefill, refillSaving, refillHistory, refillHistoryExpanded, @@ -355,8 +357,8 @@ function AppContent() { }; // For MedDetailModal: refill without form update (not editing) - const handleSubmitRefill = async (medId: number) => { - await ctx.submitRefill(medId, null, () => {}, loadMeds); + const handleSubmitRefill = async (medId: number, usePrescription: boolean = false) => { + await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription); }; // Wrapper for openEditStockModal (provides selectedMed and coverage) @@ -430,6 +432,8 @@ function AppContent() { onRefillPacksChange={setRefillPacks} refillLoose={refillLoose} onRefillLooseChange={setRefillLoose} + usePrescriptionRefill={usePrescriptionRefill} + onUsePrescriptionRefillChange={setUsePrescriptionRefill} refillSaving={refillSaving} refillHistory={refillHistory} refillHistoryExpanded={refillHistoryExpanded} diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index f0a1492..e160d9e 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -6,6 +6,7 @@ * 1. Context mode: Uses useAppContext() for all state (when no props provided) * 2. Props mode: Accepts all required data as props (for gradual adoption) */ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Lightbox, MedicationAvatar } from "../components"; import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types"; @@ -83,11 +84,13 @@ export interface MedDetailModalProps { onRefillPacksChange: (value: number) => void; refillLoose: number; onRefillLooseChange: (value: number) => void; + usePrescriptionRefill: boolean; + onUsePrescriptionRefillChange: (value: boolean) => void; refillSaving: boolean; refillHistory: RefillEntry[]; refillHistoryExpanded: boolean; onRefillHistoryExpandedChange: (value: boolean) => void; - onSubmitRefill: (medId: number) => Promise; + onSubmitRefill: (medId: number, usePrescription?: boolean) => Promise; // Edit stock state editStockFullBlisters: number; onEditStockFullBlistersChange: (value: number) => void; @@ -115,6 +118,8 @@ export function MedDetailModal({ onRefillPacksChange, refillLoose, onRefillLooseChange, + usePrescriptionRefill, + onUsePrescriptionRefillChange, refillSaving, refillHistory, refillHistoryExpanded, @@ -128,6 +133,20 @@ export function MedDetailModal({ onSubmitStockCorrection, }: MedDetailModalProps) { const { t, i18n } = useTranslation(); + const [editStockFullInput, setEditStockFullInput] = useState("0"); + const [editStockPartialInput, setEditStockPartialInput] = useState("0"); + + const parseStockInput = (value: string): number => { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; + }; + + useEffect(() => { + if (showEditStockModal) { + setEditStockFullInput(String(editStockFullBlisters)); + setEditStockPartialInput(String(editStockPartialBlisterPills)); + } + }, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills]); if (!selectedMed) return null; @@ -138,6 +157,7 @@ export function MedDetailModal({ const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text"; const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize); + const fullForBounds = Math.max(0, parseStockInput(editStockFullInput)); return (
@@ -263,6 +283,42 @@ export function MedDetailModal({
+ {/* Prescription Details Section */} + {selectedMed.prescriptionEnabled && ( +
+

{t("form.sections.prescription")}

+
+
+ {t("prescription.authorizedRefills")} + {selectedMed.prescriptionAuthorizedRefills ?? "—"} +
+
+ {t("prescription.remainingRefills")} + {selectedMed.prescriptionRemainingRefills ?? "—"} +
+
+ {t("prescription.lowThreshold")} + {selectedMed.prescriptionLowRefillThreshold ?? "—"} +
+
+ {t("prescription.expiryDate")} + + {selectedMed.prescriptionExpiryDate + ? new Date(selectedMed.prescriptionExpiryDate).toLocaleDateString( + getSystemLocale(i18n.language), + { + day: "2-digit", + month: "short", + year: "numeric", + } + ) + : "—"} + +
+
+
+ )} + {/* Intake Schedule Section */} {selectedMed.blisters.length > 0 && (
@@ -373,6 +429,12 @@ export function MedDetailModal({ entry.loosePillsAdded; return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`; })()} + {entry.usedPrescription && ( + + {" "} + 📋 + + )}
))} @@ -459,6 +521,23 @@ export function MedDetailModal({ /> )} + + {selectedMed.prescriptionEnabled && ( +
+ + + {t("prescription.remainingRefills")}: {Number(selectedMed.prescriptionRemainingRefills) || 0} + +
+ )}
@@ -468,7 +547,7 @@ export function MedDetailModal({
- - ))} +
+

{t("form.sections.general")}

+
- {fieldErrors.takenBy && {fieldErrors.takenBy}} - - - {form.packageType === "blister" ? ( - <> - - - - - - ) : ( - <> - - - - )} -
-

- {t("form.total")}: {deriveTotalFromForm(form)}{" "} - {deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")} -

-
- +
- - + +
- {/* Refill section - only shown when editing (mobile) */} - {editingId && ( -
-

{t("refill.title")}

-
+
+

{t("form.sections.stock")}

+ {form.packageType === "blister" ? ( + <> + + + + + + ) : ( + <> + + + + )} +
+

+ {t("form.total")}: {deriveTotalFromForm(form)}{" "} + {deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")} +

+
+ + +