78ee668c8b
- Added new translation keys for empty and low stock notifications in both English and German. - Implemented user authentication for planner routes and improved user settings loading. - Separated empty and low stock medications for clearer notifications. - Enhanced email notifications with detailed alerts for empty and low stock medications. - Updated user settings in the database when reminders are sent for both intake and stock notifications. - Improved form validation in the frontend with character limits and error messages. - Added CSS styles for form validation feedback and character count display.
449 lines
20 KiB
TypeScript
449 lines
20 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import nodemailer from "nodemailer";
|
||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||
import { getDateLocale, getTranslations, t, type Language } from "../i18n/translations.js";
|
||
import type { AuthUser } from "../types/fastify.js";
|
||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||
import { env } from "../plugins/env.js";
|
||
|
||
type PlannerRow = {
|
||
medicationId: number;
|
||
medicationName: string;
|
||
totalPills: number;
|
||
plannerUsage: number;
|
||
stripSize: number;
|
||
stripsNeeded: number;
|
||
stripsAvailable: number;
|
||
enough: boolean;
|
||
};
|
||
|
||
type SendEmailBody = {
|
||
email: string;
|
||
from: string;
|
||
until: string;
|
||
rows: PlannerRow[];
|
||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||
};
|
||
|
||
type LowStockItem = {
|
||
name: string;
|
||
medsLeft: number;
|
||
daysLeft: number | null;
|
||
depletionDate: string | null;
|
||
};
|
||
|
||
type ReminderEmailBody = {
|
||
email: string;
|
||
lowStock: LowStockItem[];
|
||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||
};
|
||
|
||
export async function plannerRoutes(app: FastifyInstance) {
|
||
// Add auth hook for all planner routes
|
||
app.addHook("preHandler", requireAuth);
|
||
|
||
// Helper to get user ID from request
|
||
async function getUserId(request: any): Promise<number> {
|
||
if (!env.AUTH_ENABLED) {
|
||
return getAnonymousUserId();
|
||
}
|
||
const authUser = request.user as AuthUser | null;
|
||
if (!authUser?.id) {
|
||
throw new Error("User not authenticated");
|
||
}
|
||
return authUser.id;
|
||
}
|
||
|
||
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||
|
||
if (!email || !rows || rows.length === 0) {
|
||
return reply.status(400).send({ error: "Missing email or planner data" });
|
||
}
|
||
|
||
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 reply.status(400).send({ error: "SMTP not configured" });
|
||
}
|
||
|
||
// Get locale from user settings or use the language passed in the body
|
||
let language: Language = bodyLanguage || "en";
|
||
const authUser = request.user as unknown as AuthUser | null;
|
||
if (authUser?.id) {
|
||
const userSettings = await loadUserSettings(authUser.id);
|
||
language = userSettings.language;
|
||
}
|
||
const locale = getDateLocale(language);
|
||
|
||
// Format dates for display
|
||
const fromDate = new Date(from).toLocaleDateString(locale, {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
const untilDate = new Date(until).toLocaleDateString(locale, {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
|
||
// Build HTML table with horizontal scroll for mobile
|
||
const tableRows = rows
|
||
.map(
|
||
(row) => `
|
||
<tr>
|
||
<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.totalPills}</strong></td>
|
||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong></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 ? "✓ OK" : "✗ Out of Stock"}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
`
|
||
)
|
||
.join("");
|
||
|
||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||
const summaryText =
|
||
outOfStockCount > 0
|
||
? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.`
|
||
: "✓ All medications have sufficient supply for this period.";
|
||
|
||
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-ng - 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: 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; font-size: 13px;">
|
||
${summaryText}
|
||
</p>
|
||
</div>
|
||
|
||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 550px;">
|
||
<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;">Stock</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: 16px 0;" />
|
||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist-ng Medication Planner</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const plainText = `MedAssist-ng - Demand Calculator
|
||
Supply overview from ${fromDate} to ${untilDate}
|
||
|
||
${summaryText}
|
||
|
||
${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.stripsAvailable} blisters available (${r.stripsNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")}
|
||
|
||
---
|
||
Sent from MedAssist-ng Medication Planner`;
|
||
|
||
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-ng - Supply Overview (${fromDate} - ${untilDate})`,
|
||
text: plainText,
|
||
html,
|
||
});
|
||
|
||
return reply.send({ success: true, message: "Email sent successfully" });
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||
}
|
||
});
|
||
|
||
// Reminder notification for low stock medications (supports email and push)
|
||
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
||
const { email, lowStock, language: bodyLanguage } = request.body;
|
||
|
||
if (!lowStock || lowStock.length === 0) {
|
||
return reply.status(400).send({ error: "Missing low stock data" });
|
||
}
|
||
|
||
// Load user settings
|
||
const userId = await getUserId(request);
|
||
const userSettings = await loadUserSettings(userId);
|
||
const notificationSettings = {
|
||
emailEnabled: userSettings.emailEnabled,
|
||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||
};
|
||
|
||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||
|
||
// Separate empty from low stock medications
|
||
const emptyMeds = lowStock.filter(r => r.medsLeft <= 0);
|
||
const lowMeds = lowStock.filter(r => r.medsLeft > 0);
|
||
|
||
// Send email if enabled
|
||
if (notificationSettings.emailEnabled && email) {
|
||
const smtpHost = process.env.SMTP_HOST;
|
||
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) {
|
||
// Build subject line based on what we have
|
||
let subjectText: string;
|
||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||
subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`;
|
||
} else if (emptyMeds.length > 0) {
|
||
subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`;
|
||
} else {
|
||
subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`;
|
||
}
|
||
|
||
// Build alert box based on what we have
|
||
let alertHtml: string;
|
||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||
alertHtml = `
|
||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #dc2626;">
|
||
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
|
||
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
|
||
</p>
|
||
</div>
|
||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
|
||
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
|
||
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
|
||
</p>
|
||
</div>`;
|
||
} else if (emptyMeds.length > 0) {
|
||
alertHtml = `
|
||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #dc2626;">
|
||
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
|
||
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
|
||
</p>
|
||
</div>`;
|
||
} else {
|
||
alertHtml = `
|
||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
|
||
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
|
||
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
|
||
</p>
|
||
</div>`;
|
||
}
|
||
|
||
// Build table rows with status indicator
|
||
const buildTableRow = (row: LowStockItem) => {
|
||
const isEmpty = row.medsLeft <= 0;
|
||
const statusIcon = isEmpty ? "🚨" : "⚠️";
|
||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||
return `
|
||
<tr style="background: ${rowBg};">
|
||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><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;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
|
||
</tr>`;
|
||
};
|
||
|
||
const tableRows = lowStock.map(buildTableRow).join("");
|
||
|
||
// Build description text
|
||
let descriptionText: string;
|
||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||
descriptionText = "The following medications need to be reordered:";
|
||
} else if (emptyMeds.length > 0) {
|
||
descriptionText = "The following medications are EMPTY and need to be reordered immediately:";
|
||
} else {
|
||
descriptionText = "The following medications are running low and need to be reordered:";
|
||
}
|
||
|
||
const html = `
|
||
<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;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder</h2>
|
||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${descriptionText}</p>
|
||
|
||
${alertHtml}
|
||
|
||
<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;">Sent from MedAssist-ng Medication Planner</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Build plain text with sections
|
||
let plainTextContent: string;
|
||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||
${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}
|
||
|
||
⚠️ RUNNING LOW (reorder soon):
|
||
${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`;
|
||
} else if (emptyMeds.length > 0) {
|
||
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||
${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}`;
|
||
} else {
|
||
plainTextContent = `⚠️ Running low:
|
||
${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`;
|
||
}
|
||
|
||
const plainText = `MedAssist-ng - Reorder Reminder
|
||
|
||
${plainTextContent}
|
||
|
||
---
|
||
Sent from MedAssist-ng Medication Planner`;
|
||
|
||
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-ng - ${subjectText}`,
|
||
text: plainText,
|
||
html,
|
||
});
|
||
|
||
results.email = true;
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||
results.errors.push(`Email: ${errorMessage}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send push notification if enabled
|
||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||
// Get translations based on user language (default to 'en')
|
||
const tr = getTranslations((userSettings.language as Language) || "en");
|
||
|
||
// Build clear title
|
||
const titleParts: string[] = [];
|
||
if (emptyMeds.length > 0) {
|
||
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||
}
|
||
if (lowMeds.length > 0) {
|
||
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`);
|
||
}
|
||
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||
|
||
// Build clear message with sections
|
||
const messageParts: string[] = [];
|
||
if (emptyMeds.length > 0) {
|
||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||
emptyMeds.forEach(r => messageParts.push(` • ${r.name}`));
|
||
}
|
||
if (lowMeds.length > 0) {
|
||
if (emptyMeds.length > 0) messageParts.push("");
|
||
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
|
||
lowMeds.forEach(r => messageParts.push(` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`));
|
||
}
|
||
const message = messageParts.join("\n");
|
||
|
||
try {
|
||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
// Update the reminder state to record this notification was sent
|
||
if (results.email || results.push) {
|
||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||
updateReminderSentTime("stock", channel);
|
||
|
||
// Also update user settings in database so frontend can display the info
|
||
await updateUserReminderSentTime(userId, "stock", channel);
|
||
}
|
||
|
||
// Build response message
|
||
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: `Reminder sent via ${sentChannels.join(" and ")}`
|
||
});
|
||
} else if (results.errors.length > 0) {
|
||
return reply.status(500).send({ error: results.errors.join("; ") });
|
||
} else {
|
||
return reply.status(400).send({ error: "No notification channels configured" });
|
||
}
|
||
});
|
||
}
|