feat: add reminder functionality with daily email notifications
- Implemented reminder scheduler service to check for low stock medications and send email notifications. - Added repeat daily reminders option in settings to allow users to receive daily emails while stock is low. - Updated backend settings route to include new reminder state and settings. - Enhanced frontend to manage and display reminder settings, including last automatic email sent. - Improved UI for better user experience with new styles for settings and notifications.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { updateReminderSentTime } from "../services/reminder-scheduler.js";
|
||||
|
||||
type PlannerRow = {
|
||||
medicationId: number;
|
||||
@@ -61,22 +62,22 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Build HTML table
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
const tableRows = rows
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">${row.medicationName}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${row.plannerUsage}</strong> pills</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.stripsNeeded} × ${row.stripSize}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.stripsAvailable} blisters</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">
|
||||
<span style="padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.medicationName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong> pills</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.stripsNeeded} × ${row.stripSize}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.stripsAvailable}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
|
||||
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||
row.enough
|
||||
? "background: #d1fae5; color: #065f46;"
|
||||
: "background: #fee2e2; color: #991b1b;"
|
||||
}">
|
||||
${row.enough ? "✓ Enough" : "⚠ Out of Stock"}
|
||||
${row.enough ? "✓ OK" : "⚠ Low"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -91,38 +92,40 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
: "✓ All medications have sufficient supply for this period.";
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 700px; margin: 0 auto; padding: 20px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px;">MedAssist - Demand Calculator</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 24px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">MedAssist - Demand Calculator</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
|
||||
|
||||
<div style="padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; ${
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${
|
||||
outOfStockCount > 0
|
||||
? "background: #fef2f2; border: 1px solid #fecaca;"
|
||||
: "background: #f0fdf4; border: 1px solid #bbf7d0;"
|
||||
}">
|
||||
<p style="margin: 0; color: ${outOfStockCount > 0 ? "#991b1b" : "#166534"}; font-weight: 500;">
|
||||
<p style="margin: 0; color: ${outOfStockCount > 0 ? "#991b1b" : "#166534"}; font-weight: 500; font-size: 13px;">
|
||||
${summaryText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; background: white;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Medication</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Usage</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Blisters Needed</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Available</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 500px;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Medication</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Usage</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Needed</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Available</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">Sent from MedAssist Medication Planner</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist Medication Planner</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -182,47 +185,50 @@ Sent from MedAssist Medication Planner`;
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
const tableRows = lowStock
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">${row.name}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${row.medsLeft}</strong> pills</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.daysLeft ?? 0} days</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.depletionDate ?? "-"}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px;">⚠️ MedAssist - Reorder Reminder</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 24px;">The following medications are running low and need to be reordered:</p>
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Reorder Reminder</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
|
||||
|
||||
<div style="padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; background: #fef2f2; border: 1px solid #fecaca;">
|
||||
<p style="margin: 0; color: #991b1b; font-weight: 500;">
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
|
||||
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
|
||||
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; background: white;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #6b7280;">Medication</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280;">Current Pills</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280;">Days Left</th>
|
||||
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280;">Runs Out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">Sent from MedAssist Medication Planner</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist Medication Planner</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -255,6 +261,9 @@ Sent from MedAssist Medication Planner`;
|
||||
html,
|
||||
});
|
||||
|
||||
// Update the reminder state to record this email was sent
|
||||
updateReminderSentTime();
|
||||
|
||||
return reply.send({ success: true, message: "Reminder email sent" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@@ -2,11 +2,13 @@ import { FastifyInstance } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { getReminderState } from "../services/reminder-scheduler.js";
|
||||
|
||||
type SettingsBody = {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
@@ -24,6 +26,7 @@ type NotificationSettings = {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
@@ -37,6 +40,7 @@ function loadNotificationSettings(): NotificationSettings {
|
||||
emailEnabled: saved.emailEnabled ?? false,
|
||||
notificationEmail: saved.notificationEmail ?? "",
|
||||
reminderDaysBefore: saved.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: saved.repeatDailyReminders ?? false,
|
||||
lowStockDays: saved.lowStockDays ?? 30,
|
||||
normalStockDays: saved.normalStockDays ?? 90,
|
||||
highStockDays: saved.highStockDays ?? 180,
|
||||
@@ -45,7 +49,7 @@ function loadNotificationSettings(): NotificationSettings {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
|
||||
}
|
||||
|
||||
function saveNotificationSettings(settings: NotificationSettings): void {
|
||||
@@ -56,12 +60,14 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
// Get settings - notification from JSON file, SMTP from process.env
|
||||
app.get("/settings", async (_request, reply) => {
|
||||
const notification = loadNotificationSettings();
|
||||
const reminderState = getReminderState();
|
||||
|
||||
return reply.send({
|
||||
// Notification settings (user-configurable, stored in JSON)
|
||||
emailEnabled: notification.emailEnabled,
|
||||
notificationEmail: notification.notificationEmail,
|
||||
reminderDaysBefore: notification.reminderDaysBefore,
|
||||
repeatDailyReminders: notification.repeatDailyReminders,
|
||||
lowStockDays: notification.lowStockDays,
|
||||
normalStockDays: notification.normalStockDays,
|
||||
highStockDays: notification.highStockDays,
|
||||
@@ -72,6 +78,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
smtpFrom: process.env.SMTP_FROM ?? "",
|
||||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||
hasSmtpPassword: !!process.env.SMTP_PASS,
|
||||
// Reminder state
|
||||
lastAutoEmailSent: reminderState.lastAutoEmailSent,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +92,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
emailEnabled: body.emailEnabled,
|
||||
notificationEmail: body.notificationEmail,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
repeatDailyReminders: body.repeatDailyReminders ?? false,
|
||||
lowStockDays: body.lowStockDays ?? 30,
|
||||
normalStockDays: body.normalStockDays ?? 90,
|
||||
highStockDays: body.highStockDays ?? 180,
|
||||
|
||||
Reference in New Issue
Block a user