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,50 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
email text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'user',
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
name text NOT NULL UNIQUE,
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
strips integer NOT NULL DEFAULT 0,
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
strips_per_pack integer NOT NULL DEFAULT 1,
|
||||
tabs_per_strip integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
strip_size integer NOT NULL DEFAULT 1,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
rotated_at integer,
|
||||
revoked integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
smtp_host text,
|
||||
smtp_port integer,
|
||||
smtp_user text,
|
||||
smtp_pass_encrypted text,
|
||||
smtp_from text,
|
||||
smtp_secure integer NOT NULL DEFAULT 0,
|
||||
emails_per_day integer NOT NULL DEFAULT 3,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false }
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { authRoutes } from "./routes/auth.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -65,6 +66,12 @@ const start = async () => {
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" });
|
||||
app.log.info(`Server running on ${env.PORT}`);
|
||||
|
||||
// Start the automatic reminder scheduler
|
||||
startReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
type Slice = { usage: number; every: number; start: string };
|
||||
|
||||
type NotificationSettings = {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
};
|
||||
|
||||
type ReminderState = {
|
||||
lastAutoEmailSent: string | null; // ISO date string
|
||||
lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today
|
||||
notifiedMedications: string[]; // List of medication names that have been notified (cleared when restocked)
|
||||
};
|
||||
|
||||
const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json");
|
||||
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
||||
|
||||
function loadNotificationSettings(): NotificationSettings {
|
||||
try {
|
||||
if (existsSync(notificationSettingsFile)) {
|
||||
const saved = JSON.parse(readFileSync(notificationSettingsFile, "utf-8"));
|
||||
return {
|
||||
emailEnabled: saved.emailEnabled ?? false,
|
||||
notificationEmail: saved.notificationEmail ?? "",
|
||||
reminderDaysBefore: saved.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: saved.repeatDailyReminders ?? false,
|
||||
lowStockDays: saved.lowStockDays ?? 30,
|
||||
normalStockDays: saved.normalStockDays ?? 90,
|
||||
highStockDays: saved.highStockDays ?? 180,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
|
||||
}
|
||||
|
||||
function loadReminderState(): ReminderState {
|
||||
try {
|
||||
if (existsSync(reminderStateFile)) {
|
||||
const saved = JSON.parse(readFileSync(reminderStateFile, "utf-8"));
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [] };
|
||||
}
|
||||
|
||||
function saveReminderState(state: ReminderState): void {
|
||||
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function getReminderState(): ReminderState {
|
||||
return loadReminderState();
|
||||
}
|
||||
|
||||
export function updateReminderSentTime(): void {
|
||||
const state = loadReminderState();
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
saveReminderState({
|
||||
...state,
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSlices(row: { usageJson: string; everyJson: string; startJson: string }): Slice[] {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const slices: Slice[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
slices.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return slices;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDailyUsage(slices: Slice[]): number {
|
||||
return slices.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
}
|
||||
|
||||
function calculateDepletionInfo(med: { count: number; slices: Slice[] }): { daysLeft: number | null; depletionDate: string | null } {
|
||||
const dailyUsage = calculateDailyUsage(med.slices);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
};
|
||||
|
||||
async function getMedicationsNeedingReminder(reminderDaysBefore: number): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).orderBy(medications.id);
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const slices = parseSlices(row);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices });
|
||||
|
||||
// Check if medication runs out within reminderDaysBefore days
|
||||
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
medsLeft: row.count,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lowStock;
|
||||
}
|
||||
|
||||
async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promise<{ success: boolean; error?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
const tableRows = lowStock
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
|
||||
<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: 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 - Automatic 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: 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>
|
||||
|
||||
<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: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
|
||||
🤖 Automatic reminder from MedAssist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const plainText = `MedAssist - Automatic Reorder Reminder
|
||||
|
||||
The following medications are running low:
|
||||
|
||||
${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}
|
||||
|
||||
---
|
||||
Automatic reminder from MedAssist`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `⚠️ MedAssist Auto-Reminder: ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
|
||||
const settings = loadNotificationSettings();
|
||||
|
||||
// Check if email reminders are enabled
|
||||
if (!settings.emailEnabled || !settings.notificationEmail) {
|
||||
logger.info("[Reminder] Email reminders disabled or no email configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = loadReminderState();
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
|
||||
// Get all medications that need a reminder
|
||||
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore);
|
||||
|
||||
if (allLowStock.length === 0) {
|
||||
// No low stock - clear the notified list (medications have been restocked)
|
||||
if (state.notifiedMedications.length > 0) {
|
||||
saveReminderState({
|
||||
...state,
|
||||
notifiedMedications: [],
|
||||
});
|
||||
logger.info("[Reminder] Cleared notified medications list (all restocked)");
|
||||
}
|
||||
logger.info("[Reminder] No medications need reminder");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get names of currently low stock medications
|
||||
const currentLowStockNames = allLowStock.map((m) => m.name);
|
||||
|
||||
// Remove medications from notified list that are no longer low stock (restocked)
|
||||
const stillLowStock = state.notifiedMedications.filter((name) => currentLowStockNames.includes(name));
|
||||
|
||||
// Find NEW medications that haven't been notified yet
|
||||
const newLowStock = allLowStock.filter((m) => !state.notifiedMedications.includes(m.name));
|
||||
|
||||
// Determine what to send
|
||||
let medsToNotify: LowStockItem[] = [];
|
||||
|
||||
if (settings.repeatDailyReminders) {
|
||||
// Daily reminders enabled - send for ALL low stock, but only once per day
|
||||
if (state.lastAutoEmailDate === today) {
|
||||
logger.info("[Reminder] Daily reminder already sent today, skipping");
|
||||
return;
|
||||
}
|
||||
medsToNotify = allLowStock;
|
||||
} else {
|
||||
// Only notify NEW medications (not previously notified)
|
||||
if (newLowStock.length === 0) {
|
||||
logger.info("[Reminder] No new medications to notify (already notified previously)");
|
||||
return;
|
||||
}
|
||||
medsToNotify = newLowStock;
|
||||
}
|
||||
|
||||
logger.info(`[Reminder] Sending reminder for ${medsToNotify.length} medications...`);
|
||||
|
||||
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
|
||||
|
||||
if (result.success) {
|
||||
// Update state
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])],
|
||||
});
|
||||
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
|
||||
} else {
|
||||
logger.error(`[Reminder] Failed to send email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
export function startReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
||||
// Run check immediately on startup
|
||||
logger.info("[Reminder] Starting reminder scheduler...");
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
|
||||
// Then run every hour to check (will only send once per day)
|
||||
schedulerInterval = setInterval(() => {
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
}, 60 * 60 * 1000); // Every hour
|
||||
|
||||
logger.info("[Reminder] Scheduler started - checking hourly, sending max once per day");
|
||||
}
|
||||
|
||||
export function stopReminderScheduler(): void {
|
||||
if (schedulerInterval) {
|
||||
clearInterval(schedulerInterval);
|
||||
schedulerInterval = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user