75bb7abebc
* feat: add stock correction modal with blister-based input - Add 'Correct Stock' button to medication detail modal - New modal with Full Blisters + Partial Blister Pills inputs - Auto-conversion for edge cases (full/negative partial) - New stockAdjustment field for DB corrections without touching looseTablets - New lastStockCorrectionAt timestamp to ignore old consumed doses after correction - Tracking data preserved for future statistics - Add Drizzle migrations for new columns - Add translations for en/de * fix: add stock_adjustment columns to e2e/integration test schemas
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
import nodemailer from "nodemailer";
|
|
import { eq } from "drizzle-orm";
|
|
import { db } from "../db/client.js";
|
|
import { medications, userSettings } from "../db/schema.js";
|
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
import { resolve } from "path";
|
|
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
|
import { getTranslations, t, type Language } from "../i18n/translations.js";
|
|
|
|
// Import shared utilities
|
|
import {
|
|
getTimezone,
|
|
formatInTimezone,
|
|
getCurrentHourInTimezone,
|
|
getTodayInTimezone,
|
|
getNextScheduledTime,
|
|
getMsUntilNextCheck,
|
|
parseBlisters,
|
|
calculateDailyUsage,
|
|
calculateDepletionInfo,
|
|
parseReminderState,
|
|
createDefaultReminderState,
|
|
type Blister,
|
|
type ReminderState,
|
|
} from "../utils/scheduler-utils.js";
|
|
|
|
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
|
|
|
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
|
|
|
function loadReminderState(): ReminderState {
|
|
try {
|
|
if (existsSync(reminderStateFile)) {
|
|
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return createDefaultReminderState();
|
|
}
|
|
|
|
function saveReminderState(state: ReminderState): void {
|
|
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
|
}
|
|
|
|
export function getReminderState(): ReminderState {
|
|
return loadReminderState();
|
|
}
|
|
|
|
export function updateReminderSentTime(type: "stock" | "intake" = "stock", channel: "email" | "push" | "both" = "email"): void {
|
|
const state = loadReminderState();
|
|
const today = getTodayInTimezone();
|
|
saveReminderState({
|
|
...state,
|
|
lastAutoEmailSent: new Date().toISOString(),
|
|
lastAutoEmailDate: today,
|
|
lastNotificationType: type,
|
|
lastNotificationChannel: channel,
|
|
});
|
|
}
|
|
|
|
// Update user settings in database when reminder is sent
|
|
export async function updateUserReminderSentTime(
|
|
userId: number,
|
|
type: "stock" | "intake" = "stock",
|
|
channel: "email" | "push" | "both" = "email"
|
|
): Promise<void> {
|
|
const now = new Date().toISOString();
|
|
await db.update(userSettings)
|
|
.set({
|
|
lastAutoEmailSent: now,
|
|
lastNotificationType: type,
|
|
lastNotificationChannel: channel,
|
|
})
|
|
.where(eq(userSettings.userId, userId));
|
|
}
|
|
|
|
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
|
return parseBlisters(row);
|
|
}
|
|
|
|
type LowStockItem = {
|
|
name: string;
|
|
medsLeft: number;
|
|
daysLeft: number | null;
|
|
depletionDate: string | null;
|
|
};
|
|
|
|
async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
|
|
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
|
|
|
const lowStock: LowStockItem[] = [];
|
|
|
|
for (const row of rows) {
|
|
const blisters = parseBlistersFromRow(row);
|
|
const totalPills = 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) {
|
|
lowStock.push({
|
|
name: row.name,
|
|
medsLeft: totalPills,
|
|
daysLeft,
|
|
depletionDate,
|
|
});
|
|
}
|
|
}
|
|
|
|
return lowStock;
|
|
}
|
|
|
|
async function sendReminderEmail(email: string, lowStock: LowStockItem[], language: Language, isRepeatDaily: boolean = false): Promise<{ success: boolean; error?: string }> {
|
|
const smtpHost = process.env.SMTP_HOST;
|
|
const smtpUser = process.env.SMTP_USER;
|
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
|
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 tr = getTranslations(language);
|
|
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 alertText = lowStock.length === 1
|
|
? tr.stockReminder.alertSingle
|
|
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
|
|
|
|
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;">${tr.stockReminder.title}</h2>
|
|
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${tr.stockReminder.description}</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;">
|
|
${alertText}
|
|
</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;">${tr.stockReminder.tableHeaders.medication}</th>
|
|
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.pills}</th>
|
|
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.days}</th>
|
|
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.runsOut}</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;">
|
|
${tr.stockReminder.footer}
|
|
</p>
|
|
${isRepeatDaily ? `<p style="color: #9ca3af; font-size: 11px; margin: 8px 0 0 0; font-style: italic;">${tr.stockReminder.repeatDailyNote}</p>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const plainText = `${tr.stockReminder.title}
|
|
|
|
${tr.stockReminder.description}
|
|
|
|
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
|
|
|
|
---
|
|
${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
|
|
|
const subjectPlural = lowStock.length === 1 ? "" : (language === "de" ? "e" : "s");
|
|
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
|
|
|
try {
|
|
const transporter = nodemailer.createTransport({
|
|
host: smtpHost,
|
|
port: smtpPort,
|
|
secure: smtpSecure,
|
|
auth: {
|
|
user: smtpUser,
|
|
pass: smtpPass ?? "",
|
|
},
|
|
});
|
|
|
|
await transporter.sendMail({
|
|
from: smtpFrom,
|
|
to: email,
|
|
subject: `⚠️ ${subject}`,
|
|
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> {
|
|
// Get all user settings to iterate over each user
|
|
const allUserSettings = await getAllUserSettings();
|
|
|
|
if (allUserSettings.length === 0) {
|
|
logger.info("[Reminder] No users with settings found");
|
|
return;
|
|
}
|
|
|
|
for (const userSettings of allUserSettings) {
|
|
await checkAndSendReminderForUser(userSettings, logger);
|
|
}
|
|
}
|
|
|
|
async function checkAndSendReminderForUser(
|
|
settings: UserSettings & { userId: number },
|
|
logger: { info: (msg: string) => void; error: (msg: string) => void }
|
|
): Promise<void> {
|
|
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;
|
|
|
|
if (!emailEnabled && !shoutrrrEnabled) {
|
|
return; // No stock reminder notifications enabled for this user
|
|
}
|
|
|
|
const state = loadReminderState();
|
|
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
|
|
const userStateKey = `user_${settings.userId}`;
|
|
|
|
// Get all medications that need a reminder for this user
|
|
const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language);
|
|
|
|
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 low stock medications
|
|
const emptyMeds = allLowStock.filter(m => m.medsLeft <= 0);
|
|
const lowMeds = allLowStock.filter(m => m.medsLeft > 0);
|
|
|
|
// Build clear title
|
|
const titleParts: string[] = [];
|
|
if (emptyMeds.length > 0) {
|
|
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
|
|
}
|
|
if (lowMeds.length > 0) {
|
|
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`);
|
|
}
|
|
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`;
|
|
|
|
// Build clear message with sections
|
|
const messageParts: string[] = [];
|
|
if (emptyMeds.length > 0) {
|
|
messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`);
|
|
emptyMeds.forEach(m => messageParts.push(` • ${m.name}`));
|
|
}
|
|
if (lowMeds.length > 0) {
|
|
if (emptyMeds.length > 0) messageParts.push("");
|
|
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
|
|
lowMeds.forEach(m => messageParts.push(` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`));
|
|
}
|
|
|
|
if (settings.repeatDailyReminders) {
|
|
messageParts.push("");
|
|
messageParts.push(tr.push.repeatDailyNote);
|
|
}
|
|
|
|
const message = messageParts.join("\n");
|
|
|
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
|
shoutrrrSuccess = result.success;
|
|
if (result.success) {
|
|
logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`);
|
|
} else {
|
|
logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
// Also update user settings in database so frontend can display the info
|
|
await updateUserReminderSentTime(settings.userId, "stock", channel);
|
|
}
|
|
}
|
|
|
|
let schedulerTimeout: NodeJS.Timeout | null = null;
|
|
|
|
function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
|
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
|
const nextTime = getNextScheduledTime(REMINDER_HOUR);
|
|
|
|
// Save next scheduled time to state
|
|
const state = loadReminderState();
|
|
saveReminderState({
|
|
...state,
|
|
nextScheduledCheck: nextTime.toISOString(),
|
|
});
|
|
|
|
logger.info(`[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`);
|
|
|
|
schedulerTimeout = setTimeout(() => {
|
|
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
|
// Schedule the next check after this one completes
|
|
scheduleNextCheck(logger);
|
|
}, msUntilNext);
|
|
}
|
|
|
|
export function startReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
|
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
|
|
|
// Check if we need to run immediately (missed today's check)
|
|
const state = loadReminderState();
|
|
const today = getTodayInTimezone();
|
|
const currentHour = getCurrentHourInTimezone();
|
|
|
|
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
|
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
|
logger.info("[Reminder] Missed today's check, running now...");
|
|
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
|
}
|
|
|
|
// Schedule next check at REMINDER_HOUR
|
|
scheduleNextCheck(logger);
|
|
|
|
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
|
}
|
|
|
|
export function stopReminderScheduler(): void {
|
|
if (schedulerTimeout) {
|
|
clearTimeout(schedulerTimeout);
|
|
schedulerTimeout = null;
|
|
}
|
|
}
|