BillTracker/services/notificationService.js

558 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const nodemailer = require('nodemailer');
const { getDb, getSetting } = require('../db/database');
const { decryptSecret, encryptSecret } = require('./encryptionService');
const { accountingActiveSql } = require('./paymentAccountingService');
const {
markNotificationError,
markNotificationSuccess,
markNotificationTestSuccess,
} = require('./statusRuntime');
const { localDateString } = require('../utils/dates');
const { fromCents } = require('../utils/money');
// ── Push notification channels ────────────────────────────────────────────────
const PUSH_PRIORITY = { upcoming: 'default', soon: 'high', today: 'high', overdue: 'urgent' };
const PUSH_TAGS = { upcoming: ['bell'], soon: ['warning'], today: ['rotating_light'], overdue: ['rotating_light','red_circle'] };
const DISCORD_COLOR = { upcoming: 0x4f46e5, soon: 0xd97706, today: 0xdc7308, overdue: 0xdc2626 };
function safeDecrypt(stored) {
if (!stored) return '';
try { return decryptSecret(stored); } catch { return stored; }
}
async function sendNtfy(url, token, title, body, urgency = 'soon') {
const headers = {
'Title': title,
'Priority': PUSH_PRIORITY[urgency] || 'default',
'Tags': (PUSH_TAGS[urgency] || ['bell']).join(','),
'Content-Type': 'text/plain',
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url, { method: 'POST', headers, body });
if (!res.ok) throw new Error(`ntfy returned ${res.status}`);
}
async function sendGotify(url, token, title, body, urgency = 'soon') {
const priority = { upcoming: 4, soon: 6, today: 7, overdue: 9 }[urgency] ?? 5;
const endpoint = url.replace(/\/$/, '') + '/message?token=' + encodeURIComponent(token);
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, message: body, priority }),
});
if (!res.ok) throw new Error(`Gotify returned ${res.status}`);
}
async function sendDiscord(webhookUrl, title, body, urgency = 'soon') {
const color = DISCORD_COLOR[urgency] ?? 0x4f46e5;
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{ title, description: body, color,
footer: { text: 'Bill Tracker' },
timestamp: new Date().toISOString() }],
}),
});
if (!res.ok) throw new Error(`Discord returned ${res.status}`);
}
async function sendTelegram(botToken, chatId, title, body) {
const text = `*${title}*\n${body}`;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown' }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`Telegram error: ${err.description || res.status}`);
}
}
// Dispatch push to whichever channel this user has configured.
async function sendPushToUser(user, title, body, urgency) {
if (!user.notify_push_enabled || !user.push_channel || !user.push_url) return;
const url = safeDecrypt(user.push_url);
const token = safeDecrypt(user.push_token);
switch (user.push_channel) {
case 'ntfy': return sendNtfy(url, token, title, body, urgency);
case 'gotify': return sendGotify(url, token, title, body, urgency);
case 'discord': return sendDiscord(url, title, body, urgency);
case 'telegram': return sendTelegram(token, user.push_chat_id, title, body);
default: throw new Error(`Unknown push channel: ${user.push_channel}`);
}
}
async function sendTestPush(user) {
await sendPushToUser(
user,
'Bill Tracker — Test Notification',
'Your push notification channel is working correctly.',
'upcoming',
);
}
// NOTE: the `_push` export is attached AFTER the final `module.exports = {…}`
// below — assigning it here would be clobbered by that reassignment (QA-B10-01).
// ── SMTP transport ────────────────────────────────────────────────────────────
function getSmtpPassword() {
const stored = getSetting('notify_smtp_password');
if (!stored) return '';
try {
return decryptSecret(stored);
} catch {
return stored; // legacy plaintext — works until re-saved via admin UI
}
}
function createTransport() {
const host = getSetting('notify_smtp_host');
const port = parseInt(getSetting('notify_smtp_port') || '587', 10);
const encryption = getSetting('notify_smtp_encryption') || 'starttls';
const username = getSetting('notify_smtp_username');
const password = getSmtpPassword();
const selfSigned = getSetting('notify_smtp_self_signed') === 'true';
if (!host) throw new Error('SMTP host is not configured');
return nodemailer.createTransport({
host,
port,
secure: encryption === 'ssl',
auth: username ? { user: username, pass: password } : undefined,
tls: {
rejectUnauthorized: !selfSigned,
...(encryption === 'none' ? { ignoreTLS: true } : {}),
},
});
}
function senderAddress() {
const name = getSetting('notify_sender_name') || 'Bill Tracker';
const address = getSetting('notify_sender_address');
if (!address) throw new Error('Sender address is not configured');
return `"${name}" <${address}>`;
}
// ── Email templates ───────────────────────────────────────────────────────────
const TYPE_META = {
due_3d: { subject: (b) => `Reminder: ${b.name} due in 3 days`, urgency: 'upcoming' },
due_1d: { subject: (b) => `Reminder: ${b.name} due tomorrow`, urgency: 'soon' },
due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' },
overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' },
};
const URGENCY_COLOR = {
upcoming: '#4f46e5',
soon: '#d97706',
today: '#dc7308',
overdue: '#dc2626',
};
function buildEmailHtml(bill, type, dueDate) {
const meta = TYPE_META[type];
const color = URGENCY_COLOR[meta.urgency];
const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2);
const fmt = (d) => {
if (!d) return '—';
const [y, m, day] = d.split('-');
return `${parseInt(m)}/${parseInt(day)}/${y}`;
};
// QA-B14-04: escape the bill name everywhere it lands in the HTML, including
// these message strings (previously raw — an XSS vector via the bill name).
const name = esc(bill.name);
const messages = {
due_3d: `<strong>${name}</strong> is due in 3 days.`,
due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`,
due_today: `<strong>${name}</strong> is due <strong>today</strong>.`,
overdue: `<strong>${name}</strong> was due on ${fmt(dueDate)} and has not been marked as paid.`,
};
return `<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
<tr><td align="center">
<table width="540" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;">
<tr>
<td style="background:${color};padding:16px 24px;">
<span style="color:white;font-size:18px;font-weight:700;">Bill Tracker</span>
</td>
</tr>
<tr>
<td style="padding:28px 24px;">
<p style="margin:0 0 20px;font-size:15px;color:#1a1d2e;line-height:1.6;">
${messages[type]}
</p>
<table width="100%" cellpadding="0" cellspacing="0"
style="border:1px solid #e2e4eb;border-radius:6px;overflow:hidden;font-size:14px;">
<tr style="background:#f5f6fa;">
<td style="padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;">Bill</td>
<td style="padding:10px 14px;border-bottom:1px solid #e2e4eb;">${esc(bill.name)}</td>
</tr>
<tr>
<td style="padding:10px 14px;color:#6b7280;font-weight:600;border-bottom:1px solid #e2e4eb;">Due Date</td>
<td style="padding:10px 14px;border-bottom:1px solid #e2e4eb;">${fmt(dueDate)}</td>
</tr>
<tr style="background:#f5f6fa;">
<td style="padding:10px 14px;color:#6b7280;font-weight:600;">Amount</td>
<td style="padding:10px 14px;font-weight:700;">${amount}</td>
</tr>
</table>
<p style="margin:24px 0 0;font-size:12px;color:#9ca3af;">
Sent by Bill Tracker &middot; ${new Date().toLocaleDateString()}
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Send ──────────────────────────────────────────────────────────────────────
async function sendEmail(to, subject, html) {
const transport = createTransport();
await transport.sendMail({ from: senderAddress(), to, subject, html });
}
async function sendTestEmail(to) {
const html = `<!DOCTYPE html>
<html><body style="font-family:sans-serif;padding:32px;">
<h2 style="color:#4f46e5;">Bill Tracker — Test Email</h2>
<p>Your SMTP configuration is working correctly.</p>
<p style="color:#6b7280;font-size:12px;">Sent at ${new Date().toISOString()}</p>
</body></html>`;
try {
await sendEmail(to, 'Bill Tracker — Test Email', html);
markNotificationTestSuccess();
} catch (err) {
markNotificationError(err);
throw err;
}
}
// ── Notification tracking ─────────────────────────────────────────────────────
function hasNotification(db, billId, userId, year, month, type, date) {
return !!db.prepare(`
SELECT 1 FROM notifications
WHERE bill_id=? AND user_id=? AND year=? AND month=? AND type=? AND sent_date=?
`).get(billId, userId, year, month, type, date);
}
function recordNotification(db, billId, userId, year, month, type, date) {
try {
db.prepare(`
INSERT INTO notifications (bill_id, user_id, year, month, type, sent_date)
VALUES (?, ?, ?, ?, ?, ?)
`).run(billId, userId, year, month, type, date);
} catch {
// Unique constraint: already recorded, ignore
}
}
// ── Main notification runner (called by daily worker) ─────────────────────────
async function runNotifications() {
const db = getDb();
const emailReady = getSetting('notify_smtp_enabled') === 'true'
&& !!getSetting('notify_smtp_host')
&& !!getSetting('notify_sender_address');
// Check whether any user has push notifications enabled
const pushReady = !!db.prepare(
"SELECT 1 FROM users WHERE notify_push_enabled=1 AND push_channel IS NOT NULL LIMIT 1"
).get();
// Nothing to do if neither email nor push is configured
if (!emailReady && !pushReady) return;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const today = localDateString(now);
const { getCycleRange, resolveDueDate } = require('./statusService');
// Fetch all active bills. In global-notification mode, the single global recipient
// legitimately receives every bill. In per-user mode, each recipient must only
// see their own bills — the ownership filter is applied in the loop below.
const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all();
const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
const globalRecipient = getSetting('notify_global_recipient');
// Gather recipient list
const recipients = [];
if (allowUserConfig) {
const users = db.prepare(
"SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND (notification_email IS NOT NULL AND notification_email != '' OR notify_push_enabled=1)"
).all();
recipients.push(...users);
} else if (globalRecipient) {
// Treat global recipient as a synthetic user-like object
recipients.push({
id: 0, notification_email: globalRecipient,
notify_3d: 1, notify_1d: 1, notify_due: 1, notify_overdue: 1,
});
}
if (!recipients.length) return;
const errors = [];
// Batch-fetch all payments for active bills this cycle to avoid N+1 queries.
// Bills use different cycle ranges per bill, so we use a broad month window
// and the per-bill cycle check happens in memory below.
const billIds = bills.map(b => b.id);
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = localDateString(new Date(year, month, 0));
const paidMap = new Map();
if (billIds.length > 0) {
const placeholders = billIds.map(() => '?').join(',');
const paidRows = db.prepare(`
SELECT bill_id, SUM(amount) AS paid_sum
FROM payments
WHERE bill_id IN (${placeholders})
AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
GROUP BY bill_id
`).all(...billIds, monthStart, monthEnd);
for (const row of paidRows) paidMap.set(row.bill_id, row.paid_sum);
}
// Batch-fetch all notifications already sent today to avoid N×M per-bill-per-recipient queries.
const sentRows = db.prepare(`
SELECT bill_id, user_id, type FROM notifications
WHERE year = ? AND month = ? AND sent_date = ?
`).all(year, month, today);
const sentSet = new Set(sentRows.map(n => `${n.bill_id}:${n.user_id}:${n.type}`));
for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) continue;
const totalPaid = paidMap.get(bill.id) ?? 0;
const isPaid = totalPaid >= bill.expected_amount;
if (isPaid) continue;
const due = new Date(dueDate + 'T00:00:00');
// Compare calendar days, not timestamps, to avoid same-day bugs
// (e.g., due today at midnight vs now at 3pm would give -0.625 days → floors to -1)
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
const diffDays = Math.round((dueDay - todayDate) / 86400000);
// Determine which type applies today
let type = null;
if (diffDays === 3) type = 'due_3d';
else if (diffDays === 1) type = 'due_1d';
else if (diffDays === 0) type = 'due_today';
else if (diffDays < 0) type = 'overdue';
if (!type) continue;
// Defensive: warn if a bill somehow has no owner
if (!bill.user_id) {
console.warn(`[notifications] Bill id=${bill.id} name="${bill.name}" has no user_id — skipping`);
continue;
}
for (const recipient of recipients) {
// In per-user mode, only send bills belonging to this recipient
if (allowUserConfig && bill.user_id !== recipient.id) continue;
// Check recipient's preferences
if (type === 'due_3d' && !recipient.notify_3d) continue;
if (type === 'due_1d' && !recipient.notify_1d) continue;
if (type === 'due_today' && !recipient.notify_due) continue;
if (type === 'overdue' && !recipient.notify_overdue) continue;
if (sentSet.has(`${bill.id}:${recipient.id}:${type}`)) continue;
const meta = TYPE_META[type];
const subject = meta.subject(bill);
const urgency = meta.urgency;
const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2);
const pushBody = `${subject} · ${amount}`;
let sent = false;
// Email
if (recipient.notification_email) {
const html = buildEmailHtml(bill, type, dueDate);
try {
await sendEmail(recipient.notification_email, subject, html);
sent = true;
} catch (err) {
errors.push(`email/${recipient.notification_email}/${bill.name}: ${err.message}`);
}
}
// Push (ntfy / Gotify / Discord / Telegram)
if (recipient.notify_push_enabled && recipient.push_channel && recipient.push_url) {
try {
await sendPushToUser(recipient, subject, pushBody, urgency);
sent = true;
} catch (err) {
errors.push(`push/${recipient.push_channel}/${bill.name}: ${err.message}`);
}
}
if (sent) {
recordNotification(db, bill.id, recipient.id, year, month, type, today);
sentSet.add(`${bill.id}:${recipient.id}:${type}`);
}
}
}
if (errors.length) {
markNotificationError(new Error(errors.join('; ')));
console.error('[notifications] Send errors:', errors);
} else {
markNotificationSuccess();
console.log(`[notifications] Run complete for ${today}`);
}
}
// ── Drift / price-change digest email ────────────────────────────────────────
function fmtAmt(n) {
return '$' + Number(n || 0).toFixed(2);
}
function buildDriftDigestHtml(bills) {
const color = '#d97706'; // amber-600
const rows = bills.map(b => {
const sign = b.direction === 'up' ? '+' : '';
const arrow = b.direction === 'up' ? '▲' : '▼';
const arrowColor = b.direction === 'up' ? '#d97706' : '#0d9488';
return `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;">${esc(b.name)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;text-decoration:line-through;color:#9ca3af;">${fmtAmt(b.expected_amount)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;font-weight:600;">${fmtAmt(b.recent_amount)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px;color:${arrowColor};font-weight:700;">${arrow} ${sign}${b.drift_pct}%</td>
</tr>`;
}).join('');
return `<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
<tr><td align="center">
<table width="540" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;">
<tr>
<td style="background:${color};padding:16px 24px;">
<p style="margin:0;color:#fff;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Bill Tracker</p>
<h1 style="margin:4px 0 0;color:#fff;font-size:20px;font-weight:700;">Price Changes Detected</h1>
</td>
</tr>
<tr>
<td style="padding:20px 24px 8px;">
<p style="margin:0;color:#374151;font-size:14px;">
The following bills have been paid at amounts different from their expected amounts for the past ${bills[0]?.months_sampled ?? 2}+ months.
</p>
</td>
</tr>
<tr>
<td style="padding:8px 24px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#fef9c3;">
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Bill</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Was</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Now ~</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Change</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</td>
</tr>
<tr>
<td style="padding:0 24px 24px;">
<p style="margin:0;color:#6b7280;font-size:13px;">
You can update the expected amounts or dismiss these alerts in Bill Tracker.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
async function runDriftNotifications() {
if (getSetting('notify_smtp_enabled') !== 'true') return;
if (!getSetting('notify_smtp_host')) return;
if (!getSetting('notify_sender_address')) return;
const db = getDb();
const { getDriftReport } = require('./driftService');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const today = localDateString(now);
const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
const globalRecipient = getSetting('notify_global_recipient');
const recipients = [];
if (allowUserConfig) {
const users = db.prepare(
"SELECT * FROM users WHERE active=1 AND role='user' AND notifications_enabled=1 AND notify_amount_change=1 AND notification_email IS NOT NULL AND notification_email != ''"
).all();
recipients.push(...users);
} else if (globalRecipient) {
recipients.push({ id: 0, notification_email: globalRecipient, notify_amount_change: 1 });
}
for (const recipient of recipients) {
try {
const report = getDriftReport(recipient.id, now);
const newBills = (report.bills || []).filter(b =>
!hasNotification(db, b.id, recipient.id, year, month, 'amount_change', today)
);
if (!newBills.length) continue;
await sendEmail(
recipient.notification_email,
'Price Change Alert: Your bill amounts have changed',
buildDriftDigestHtml(newBills)
);
for (const b of newBills) {
recordNotification(db, b.id, recipient.id, year, month, 'amount_change', today);
}
} catch (err) {
console.error('[drift notifications] Error for recipient', recipient.notification_email, ':', err.message);
}
}
}
module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
// Push helpers, exposed for the test-push route + tests. Assigned AFTER the line
// above so it isn't clobbered by the reassignment (QA-B10-01: previously set
// before it, leaving `_push` undefined → "Send test push" always 500'd).
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
module.exports._email = { buildEmailHtml, esc };