548 lines
22 KiB
JavaScript
548 lines
22 KiB
JavaScript
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');
|
||
|
||
// ── 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',
|
||
);
|
||
}
|
||
|
||
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
|
||
|
||
// ── 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 = '$' + Number(bill.expected_amount || 0).toFixed(2);
|
||
const fmt = (d) => {
|
||
if (!d) return '—';
|
||
const [y, m, day] = d.split('-');
|
||
return `${parseInt(m)}/${parseInt(day)}/${y}`;
|
||
};
|
||
|
||
const messages = {
|
||
due_3d: `<strong>${bill.name}</strong> is due in 3 days.`,
|
||
due_1d: `<strong>${bill.name}</strong> is due <strong>tomorrow</strong>.`,
|
||
due_today: `<strong>${bill.name}</strong> is due <strong>today</strong>.`,
|
||
overdue: `<strong>${bill.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 · ${new Date().toLocaleDateString()}
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
// ── 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 = '$' + Number(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 };
|