feat: implement push notification support for low stock reminders and enhance email validation

This commit is contained in:
Daniel Volz
2025-12-21 08:40:06 +01:00
parent 4161fc7d8a
commit 2054fc0b56
4 changed files with 220 additions and 227 deletions
+123 -80
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify";
import nodemailer from "nodemailer";
import { updateReminderSentTime } from "../services/reminder-scheduler.js";
import { loadNotificationSettings, sendShoutrrrNotification } from "./settings.js";
type PlannerRow = {
medicationId: number;
@@ -169,74 +170,76 @@ Sent from MedAssist Medication Planner`;
}
});
// Reminder email for low stock medications
// Reminder notification for low stock medications (supports email and push)
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
const { email, lowStock } = request.body;
if (!email || !lowStock || lowStock.length === 0) {
return reply.status(400).send({ error: "Missing email or low stock data" });
if (!lowStock || lowStock.length === 0) {
return reply.status(400).send({ error: "Missing low stock data" });
}
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;
const notificationSettings = loadNotificationSettings();
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
if (!smtpHost || !smtpUser) {
return reply.status(400).send({ error: "SMTP not configured" });
}
// 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_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
// Build HTML table with horizontal scroll for mobile
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("");
if (smtpHost && smtpUser) {
// Build HTML table with horizontal scroll for mobile
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 - 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>
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 - 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;">Sent from MedAssist Medication Planner</p>
</div>
</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;">Sent from MedAssist Medication Planner</p>
</div>
</div>
`;
const plainText = `MedAssist - Reorder Reminder
const plainText = `MedAssist - Reorder Reminder
The following medications are running low:
@@ -245,32 +248,72 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} d
---
Sent from MedAssist Medication Planner`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
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 - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
text: plainText,
html,
});
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `⚠️ MedAssist - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
text: plainText,
html,
});
// Update the reminder state to record this email was sent
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) {
const title = `${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`;
const message = lowStock
.map((r) => `- ${r.name}: ${r.medsLeft} pills (${r.daysLeft ?? 0} days)`)
.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) {
updateReminderSentTime();
}
return reply.send({ success: true, message: "Reminder email sent" });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
// 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" });
}
});
}
+5 -2
View File
@@ -200,12 +200,15 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
let headers: Record<string, string> = {};
let body: string | undefined;
// Remove emojis from title for header compatibility (ntfy doesn't support unicode in headers)
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|/gu, "").trim();
// Handle different URL formats
if (urlStr.startsWith("ntfy://")) {
// ntfy://[user:pass@]host/topic -> https://host/topic
const parsed = new URL(urlStr.replace("ntfy://", "https://"));
targetUrl = `https://${parsed.host}${parsed.pathname}`;
headers = { "Title": title };
headers = { "Title": cleanTitle, "Tags": "warning" };
body = message;
// Handle basic auth if present
@@ -215,7 +218,7 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
// Direct ntfy HTTPS URL
targetUrl = urlStr;
headers = { "Title": title };
headers = { "Title": cleanTitle, "Tags": "warning" };
body = message;
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
// Generic webhook URL - send as JSON
+65 -55
View File
@@ -233,6 +233,16 @@ export default function App() {
async function saveSettings(e: React.FormEvent) {
e.preventDefault();
// Validate email if email notifications are enabled
if (settings.emailEnabled && settings.notificationEmail) {
const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i;
if (!emailRegex.test(settings.notificationEmail)) {
setTestEmailResult({ success: false, message: "Invalid email address" });
return;
}
}
setSettingsSaving(true);
setTestEmailResult(null);
@@ -531,13 +541,13 @@ export default function App() {
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={
<>
{settings.emailEnabled && settings.notificationEmail && (
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<section className="email-status-bar">
<span className="email-status-icon">📧</span>
<span className="email-status-icon">{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"}</span>
<span className="email-status-text">
Automatic reminders active {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)}
</span>
<span className="email-status-recipient"> {settings.notificationEmail}</span>
{settings.emailEnabled && settings.notificationEmail && <span className="email-status-recipient"> {settings.notificationEmail}</span>}
</section>
)}
<section className="grid">
@@ -576,10 +586,10 @@ export default function App() {
);
})}
</div>
{settings.emailEnabled && settings.notificationEmail && (
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
{sendingReminderEmail ? "Sending..." : "📧 Send Reminder Now"}
{sendingReminderEmail ? "Sending..." : "🔔 Send Reminder Now"}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
@@ -970,10 +980,44 @@ export default function App() {
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>📊 Stock Display</h3>
</div>
<div className="setting-group">
<label>
<span className="field-label">Low Stock (days)</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="365"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span className="info-tooltip" data-tooltip="Yellow warning color when supply is below this threshold. Only affects display, not reminders."></span>
</div>
</label>
<label>
<span className="field-label">High Stock (days)</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="730"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span className="info-tooltip" data-tooltip="Green with star when supply is above this threshold. Only affects display, not reminders."></span>
</div>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3> Reminder Threshold</h3>
<span className="info-tooltip" title="Applies to both Email and Push notifications. When a medication's remaining supply falls below this threshold, you'll receive a notification."></span>
<span className="info-tooltip" data-tooltip="Applies to both Email and Push notifications. When a medication's remaining supply falls below this threshold, you'll receive a notification."></span>
</div>
<div className="threshold-input">
<label>
@@ -993,7 +1037,7 @@ export default function App() {
<div className="setting-row compact">
<label className="setting-label">
Repeat daily
<span className="info-tooltip small" title="When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked."></span>
<span className="info-tooltip small" data-tooltip="When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked."></span>
</label>
<label className="toggle-switch small">
<input
@@ -1019,6 +1063,9 @@ export default function App() {
value={settings.notificationEmail}
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
placeholder="your@email.com"
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
required
autoComplete="email"
/>
</label>
</div>
@@ -1027,7 +1074,7 @@ export default function App() {
SMTP: {settings.smtpHost || "Not configured"}:{settings.smtpPort}
{settings.hasSmtpPassword && " ✓"}
</span>
<span className="info-tooltip small" title={`Host: ${settings.smtpHost || "—"}\nPort: ${settings.smtpPort}\nFrom: ${settings.smtpFrom || "—"}\n\nConfigured via .env file`}></span>
<span className="info-tooltip small" data-tooltip={`Host: ${settings.smtpHost || "—"}\nPort: ${settings.smtpPort}\nFrom: ${settings.smtpFrom || "—"}\n\nConfigured via .env file`}></span>
</div>
<div className="setting-actions">
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
@@ -1051,14 +1098,16 @@ export default function App() {
<div className="setting-group">
<label className="full">
<span className="field-label">Notification URL</span>
<input
type="url"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder="https://ntfy.sh/your-topic"
pattern="(https?|ntfy|discord|telegram|slack):\/\/.+"
/>
<span className="field-examples">e.g. https://ntfy.sh/mytopic, discord://token@id</span>
<div className="input-with-tooltip">
<input
type="url"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder="https://ntfy.sh/your-topic"
pattern="(https?|ntfy|discord|telegram|slack):\/\/.+"
/>
<span className="info-tooltip" data-tooltip="e.g. https://ntfy.sh/mytopic, discord://token@id"></span>
</div>
</label>
</div>
<div className="setting-actions">
@@ -1074,45 +1123,6 @@ export default function App() {
</div>
)}
<div className="setting-section">
<div className="section-header">
<h3>📊 Stock Display</h3>
<span className="info-tooltip" title="These thresholds control the color-coding in the medication overview. They don't affect when reminders are sent."></span>
</div>
<div className="threshold-grid">
<label className="threshold-card warning">
<span className="threshold-icon"></span>
<span className="threshold-title">Low Stock</span>
<div className="threshold-input-wrap">
<input
type="number"
min="1"
max="365"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span>days</span>
</div>
<span className="threshold-desc">Yellow warning</span>
</label>
<label className="threshold-card success">
<span className="threshold-icon"></span>
<span className="threshold-title">High Stock</span>
<div className="threshold-input-wrap">
<input
type="number"
min="1"
max="730"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span>days</span>
</div>
<span className="threshold-desc">Green with star</span>
</label>
</div>
</div>
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
{settingsSaving ? "Saving..." : settingsSaved && !settingsChanged ? "Saved ✓" : "Save Settings"}
+27 -90
View File
@@ -891,7 +891,7 @@ textarea {
}
.info-tooltip::after {
content: attr(title);
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
@@ -904,6 +904,8 @@ textarea {
font-weight: 400;
line-height: 1.4;
white-space: pre-line;
text-transform: none;
letter-spacing: normal;
min-width: 200px;
max-width: 280px;
text-align: left;
@@ -1084,13 +1086,13 @@ textarea {
font-size: 0.9rem;
}
/* Field Label */
/* Field Label with inline tooltip */
.field-label {
display: block;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
@@ -1103,6 +1105,25 @@ textarea {
font-family: "SF Mono", "Fira Code", monospace;
}
/* Input with tooltip inside */
.input-with-tooltip {
position: relative;
display: flex;
align-items: center;
}
.input-with-tooltip input {
width: 100%;
padding-right: 2.5rem;
}
.input-with-tooltip .info-tooltip {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
}
/* SMTP Info */
.smtp-info {
display: flex;
@@ -1120,86 +1141,6 @@ textarea {
font-family: "SF Mono", "Fira Code", monospace;
}
/* Threshold Cards */
.threshold-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.threshold-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 1rem;
background: var(--bg-input);
border: 2px solid var(--border-primary);
border-radius: 12px;
cursor: pointer;
transition: border-color 0.15s;
}
.threshold-card:hover,
.threshold-card:focus-within {
border-color: var(--accent);
}
.threshold-card.warning {
border-color: rgba(252, 211, 77, 0.3);
}
.threshold-card.warning:hover,
.threshold-card.warning:focus-within {
border-color: #fcd34d;
}
.threshold-card.success {
border-color: rgba(57, 217, 138, 0.3);
}
.threshold-card.success:hover,
.threshold-card.success:focus-within {
border-color: #39d98a;
}
.threshold-icon {
font-size: 1.25rem;
}
.threshold-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-primary);
}
.threshold-input-wrap {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.25rem;
}
.threshold-input-wrap input {
width: 60px;
text-align: center;
padding: 0.35rem;
font-size: 1.1rem;
font-weight: 600;
}
.threshold-input-wrap span {
font-size: 0.8rem;
color: var(--text-secondary);
}
.threshold-desc {
font-size: 0.7rem;
color: var(--text-secondary);
}
@media (max-width: 500px) {
.channels-overview {
flex-direction: column;
@@ -1215,10 +1156,6 @@ textarea {
width: 100%;
align-items: center;
}
.threshold-grid {
grid-template-columns: 1fr;
}
}
.setting-actions {