feat: push notification channels (ntfy/Gotify/Discord/Telegram) and cash flow projection
- Wire four push channels into runNotifications() with urgency mapping - push_url and push_token encrypted at rest via AES-256-GCM - Profile page Push card with master toggle, channel picker, test button - Calendar CashFlowCard with period/month projections and negative alert - Tracker card shows projected amount when cashflow data available
This commit is contained in:
parent
c26880da89
commit
36f7191289
|
|
@ -16,6 +16,10 @@
|
|||
|
||||
### ✨ Features
|
||||
|
||||
- **Bill due notifications — push channels (ntfy / Gotify / Discord / Telegram)** — The email notification system (`runNotifications()`) was already fully built and running in the daily worker at 6 AM. What was missing was push notification support. Four new channels are now wired in: ntfy (HTTP POST with priority/tags headers and optional Bearer auth), Gotify (JSON message with urgency-mapped priority 4–9), Discord (webhook embed with urgency-matched color and timestamp), and Telegram (Bot API `sendMessage` with Markdown). Urgency levels (`upcoming`, `soon`, `today`, `overdue`) map to channel-appropriate priorities and ntfy tags (`bell`, `warning`, `rotating_light`, `red_circle`). The early-exit guard in `runNotifications()` was loosened — notifications now fire if either SMTP or any user's push is configured. Each recipient can receive both email and push for the same bill. `push_url` and `push_token` are encrypted at rest with the existing AES-256-GCM service. DB migration `v0.80` adds five columns to `users`: `notify_push_enabled`, `push_channel`, `push_url`, `push_token`, `push_chat_id`. The Profile page gains a "Push Notifications" card with a master toggle, pill-button channel picker, context-aware inputs (URL, token shown as "✓ saved" rather than pre-filled, Telegram-only chat ID field), and a "Send test" button that fires `POST /api/notifications/test-push` and surfaces the exact error if the channel is misconfigured.
|
||||
|
||||
- **Cash flow projection** — A new `CashFlowCard` on the Calendar page answers "what will I have left after all my bills clear?" — distinct from the existing remaining balance which only reflects what's already been paid. The card shows two panels in the first half of the month (by period end, by month end) and collapses to one panel in the second half since the dates converge. Progress bars are amount-based (`$420 of $650 paid`) rather than count-based so high-value bills are weighted correctly. When any projection goes negative a prominent red alert banner appears with the shortfall amount and a prompt to review unpaid bills or adjust starting amounts. The "X unpaid →" count is a live link that opens the Tracker pre-filtered to exactly those bills for that period. On TrackerPage the Starting card hint now shows `→ $1,247 projected by Jun 14` when cashflow data is available, surfacing the projection without leaving the tracker view. When bank tracking is active the projection uses the live effective bank balance as its starting point. Backend: a `cashflow` block added to the `trackerService` response containing period and month projections, amount-paid totals, paid/total counts, and an `end_label` string for the period cutoff date.
|
||||
|
||||
- **Bill bank matching rules** — Bills can now be linked to bank transaction patterns so payments import automatically without manual matching. A new "Bank matching rules" section in the Bill Modal (Transactions tab) shows all existing patterns for a bill as removable chips and lets the user add new ones by typing a merchant name or picking from a dropdown of recent unmatched transactions. As the user types, a live preview badge shows how many existing unmatched transactions the pattern would match (debounced, updates as-you-type). If the pattern is already claimed by another bill a conflict warning appears inline with the other bill's name, prompting the user to be more specific. On save the rule is applied retroactively — `syncBillPaymentsFromSimplefin` runs immediately and a green feedback banner reports how many historical payments were imported (e.g. "3 existing payments imported from your transaction history"). Bills with at least one active rule show a green **Bank** chip in the bill list with a tooltip. Four new endpoints: `GET /api/bills/:id/merchant-rules` (list rules + suggestions), `GET /api/bills/:id/merchant-rules/preview?merchant=X` (match count + conflict check), `POST /api/bills/:id/merchant-rules` (add + retroactive apply), `DELETE /api/bills/:id/merchant-rules/:ruleId` (remove).
|
||||
|
||||
- **SimpleFIN bank budget tracking** — A new opt-in mode replaces manually-entered starting amounts with the live balance of a connected bank account. When enabled from the Bank Sync settings page (Data → Bank Sync → Bank Budget Tracking), the user selects which financial account to track and configures a pending payment window (0–7 days, default 3). Budget remaining is calculated as: `bank balance − pending payments − unpaid bills this month`. Bills already marked paid are not double-counted — the bank balance already reflects them. Payments made within the pending window appear in the tracker with an amber **Pending** badge, flagging that the bank may not have processed the debit yet. The CalendarPage Monthly Money Map switches to four live metrics (Balance / Pending / Unpaid Bills / After Bills) when bank mode is active. The TrackerPage starting-amounts card shows the account name and "live balance" hint; the manual-edit button is hidden since there is nothing to manually set. Implementation: three new `user_settings` keys (`bank_tracking_enabled`, `bank_tracking_account_id`, `bank_tracking_pending_days`), a new `GET /api/data-sources/accounts/all` endpoint for the account picker, `buildBankTrackingSummary()` in both `summary.js` and `trackerService.js`, and `pending_cleared` flag on tracker rows.
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const api = {
|
|||
notifAdmin: () => get('/notifications/admin'),
|
||||
saveNotifAdmin: (data) => put('/notifications/admin', data),
|
||||
testEmail: (data) => post('/notifications/test', data),
|
||||
testPushNotification: () => post('/notifications/test-push', {}),
|
||||
notifMe: () => get('/notifications/me'),
|
||||
saveNotifMe: (data) => put('/notifications/me', data),
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import { toast } from 'sonner';
|
||||
import {
|
||||
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
|
||||
Bell, SendHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
|
@ -385,6 +386,188 @@ function NotificationPreferences({ settings, onSaved }) {
|
|||
);
|
||||
}
|
||||
|
||||
const PUSH_CHANNELS = [
|
||||
{ value: 'ntfy', label: 'ntfy', urlLabel: 'Topic URL', urlHint: 'https://pulse.scheller.ltd/bills', tokenLabel: 'Access token (optional)' },
|
||||
{ value: 'gotify', label: 'Gotify', urlLabel: 'Server URL', urlHint: 'http://192.168.1.11:8077', tokenLabel: 'App token' },
|
||||
{ value: 'discord', label: 'Discord', urlLabel: 'Webhook URL', urlHint: 'https://discord.com/api/webhooks/…', tokenLabel: null },
|
||||
{ value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' },
|
||||
];
|
||||
|
||||
function PushNotifications({ settings, onSaved }) {
|
||||
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
|
||||
const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
|
||||
const [url, setUrl] = useState(settings.push_url || '');
|
||||
const [token, setToken] = useState(''); // never pre-filled for security
|
||||
const [chatId, setChatId] = useState(settings.push_chat_id || '');
|
||||
const [tokenSet, setTokenSet] = useState(!!settings.push_token_set);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0];
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const patch = {
|
||||
notify_push_enabled: enabled,
|
||||
push_channel: channel,
|
||||
push_url: url.trim() || null,
|
||||
push_chat_id: chatId.trim() || null,
|
||||
};
|
||||
if (token.trim()) patch.push_token = token.trim();
|
||||
await api.updateProfileSettings(patch);
|
||||
setTokenSet(!!token.trim() || tokenSet);
|
||||
setToken('');
|
||||
toast.success('Push notification settings saved.');
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save push settings.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
await api.testPushNotification();
|
||||
toast.success('Test notification sent — check your device.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Test failed. Check your channel settings.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard title="Push Notifications" icon={Bell} subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram.">
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
|
||||
{/* Master toggle — same CheckRow pattern as the email section */}
|
||||
<CheckRow
|
||||
id="push-enabled"
|
||||
label="Enable push notifications"
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
{enabled && (
|
||||
<p className="text-xs text-muted-foreground -mt-3 pl-1">
|
||||
Sent at 6 AM alongside email reminders. Use the test button below to verify immediately.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* Channel picker */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Channel</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PUSH_CHANNELS.map(c => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
onClick={() => setChannel(c.value)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
channel === c.value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel-specific inputs */}
|
||||
<div className="space-y-3 max-w-md">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{ch.urlLabel}</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder={ch.urlHint}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ch.tokenLabel && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{ch.tokenLabel}
|
||||
{tokenSet && !token && (
|
||||
<span className="ml-2 text-emerald-600 dark:text-emerald-400">✓ saved</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token…'}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ch.chatIdLabel && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{ch.chatIdLabel}</label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={e => setChatId(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Send /start to your bot, then visit{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">api.telegram.org/bot{'<token>'}/getUpdates</code>
|
||||
{' '}to find your chat ID.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel hints */}
|
||||
{channel === 'ntfy' && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Your ntfy server is running at{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">pulse.scheller.ltd</code>.
|
||||
Set the topic URL to{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">https://pulse.scheller.ltd/your-topic</code>.
|
||||
</p>
|
||||
)}
|
||||
{channel === 'gotify' && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Your Gotify server is at{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">notify.originalsinners.org</code>.
|
||||
Create an app in Gotify and paste the app token above.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={test}
|
||||
disabled={testing || !enabled || !url.trim()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{testing
|
||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Sending…</>
|
||||
: <><SendHorizontal className="h-3.5 w-3.5" />Send test</>}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePassword() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
|
@ -519,8 +702,9 @@ export default function ProfilePage() {
|
|||
<div id="security" className="scroll-mt-6">
|
||||
<ChangePassword />
|
||||
</div>
|
||||
<div id="notifications" className="scroll-mt-6">
|
||||
<div id="notifications" className="scroll-mt-6 space-y-4">
|
||||
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
||||
{!loading && <PushNotifications settings={settings} onSaved={() => api.profileSettings().then(setSettings).catch(() => {})} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2641,6 +2641,23 @@ function runMigrations() {
|
|||
console.warn('[v0.79] OIDC client secret encryption migration failed:', err.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.80',
|
||||
description: 'users: push notification columns (ntfy / Gotify / Discord / Telegram)',
|
||||
dependsOn: ['v0.79'],
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
const add = (col, def) => {
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
||||
};
|
||||
add('notify_push_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
add('push_channel', 'TEXT');
|
||||
add('push_url', 'TEXT');
|
||||
add('push_token', 'TEXT');
|
||||
add('push_chat_id', 'TEXT');
|
||||
console.log('[v0.80] push notification columns added');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ const router = express.Router();
|
|||
const { getDb, getSetting, setSetting } = require('../db/database');
|
||||
const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth');
|
||||
const { sendTestEmail } = require('../services/notificationService');
|
||||
const { sendTestPush } = require('../services/notificationService')._push || {};
|
||||
const { decryptSecret } = require('../services/encryptionService');
|
||||
const { encryptSecret } = require('../services/encryptionService');
|
||||
|
||||
// ── Admin: SMTP configuration ─────────────────────────────────────────────────
|
||||
|
|
@ -109,4 +111,34 @@ router.put('/me', requireAuth, requireUser, (req, res) => {
|
|||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /api/notifications/test-push — send a test push notification to the current user
|
||||
router.post('/test-push', requireAuth, requireUser, async (req, res) => {
|
||||
const db = getDb();
|
||||
const user = db.prepare(`
|
||||
SELECT notify_push_enabled, push_channel, push_url, push_token, push_chat_id
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
if (!user?.push_channel || !user?.push_url) {
|
||||
return res.status(400).json({ error: 'Push notification channel not configured' });
|
||||
}
|
||||
|
||||
// Decrypt for sending
|
||||
const safeDecrypt = (v) => { try { return v ? decryptSecret(v) : ''; } catch { return v || ''; } };
|
||||
const userForPush = {
|
||||
...user,
|
||||
notify_push_enabled: 1,
|
||||
push_url: safeDecrypt(user.push_url),
|
||||
push_token: safeDecrypt(user.push_token),
|
||||
};
|
||||
|
||||
try {
|
||||
if (!sendTestPush) throw new Error('Push service not initialised');
|
||||
await sendTestPush(userForPush);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, coo
|
|||
const { getImportHistory } = require('../services/spreadsheetImportService');
|
||||
const { logAudit } = require('../services/auditService');
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
const { encryptSecret, decryptSecret } = require('../services/encryptionService');
|
||||
|
||||
// All profile routes require authentication — enforced in server.js.
|
||||
// req.user is always the signed-in user; user_id is never accepted from the body.
|
||||
|
|
@ -131,12 +132,16 @@ router.get('/settings', (req, res) => {
|
|||
const db = getDb();
|
||||
const user = db.prepare(`
|
||||
SELECT notification_email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
||||
notify_push_enabled, push_channel, push_url, push_token, push_chat_id
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Decrypt push secrets — return masked indicator for token (never the raw value)
|
||||
const pushUrlDecrypted = user.push_url ? (() => { try { return decryptSecret(user.push_url); } catch { return user.push_url; } })() : null;
|
||||
|
||||
res.json({
|
||||
notification_email: user.notification_email || null,
|
||||
notifications_enabled: !!user.notifications_enabled,
|
||||
|
|
@ -145,17 +150,25 @@ router.get('/settings', (req, res) => {
|
|||
notify_due: !!user.notify_due,
|
||||
notify_overdue: !!user.notify_overdue,
|
||||
notify_amount_change: user.notify_amount_change !== 0,
|
||||
notify_push_enabled: !!user.notify_push_enabled,
|
||||
push_channel: user.push_channel || null,
|
||||
push_url: pushUrlDecrypted || null,
|
||||
push_token_set: !!user.push_token,
|
||||
push_chat_id: user.push_chat_id || null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/settings ───────────────────────────────────────────────
|
||||
// Updates user-owned notification preferences only.
|
||||
// Cannot modify global/admin/SMTP settings through this endpoint.
|
||||
const VALID_PUSH_CHANNELS = new Set(['ntfy', 'gotify', 'discord', 'telegram']);
|
||||
|
||||
router.patch('/settings', (req, res) => {
|
||||
const db = getDb();
|
||||
const {
|
||||
notification_email, email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
||||
notify_push_enabled, push_channel, push_url, push_token, push_chat_id,
|
||||
} = req.body;
|
||||
|
||||
const nextEmail = notification_email !== undefined ? notification_email : email;
|
||||
|
|
@ -169,9 +182,14 @@ router.patch('/settings', (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (push_channel !== undefined && push_channel !== null && !VALID_PUSH_CHANNELS.has(push_channel)) {
|
||||
return res.status(400).json({ error: `push_channel must be one of: ${[...VALID_PUSH_CHANNELS].join(', ')}` });
|
||||
}
|
||||
|
||||
const current = db.prepare(`
|
||||
SELECT notification_email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
||||
notify_push_enabled, push_channel, push_url, push_token, push_chat_id
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
|
|
@ -182,6 +200,13 @@ router.patch('/settings', (req, res) => {
|
|||
const boolVal = (incoming, fallback) =>
|
||||
incoming !== undefined ? (incoming ? 1 : 0) : fallback;
|
||||
|
||||
// Encrypt push_url and push_token before storing
|
||||
const encryptOrNull = (v, fallback) => {
|
||||
if (v === undefined) return fallback;
|
||||
if (!v) return null;
|
||||
try { return encryptSecret(String(v).trim()); } catch { return String(v).trim(); }
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET
|
||||
notification_email = ?,
|
||||
|
|
@ -191,6 +216,11 @@ router.patch('/settings', (req, res) => {
|
|||
notify_due = ?,
|
||||
notify_overdue = ?,
|
||||
notify_amount_change = ?,
|
||||
notify_push_enabled = ?,
|
||||
push_channel = ?,
|
||||
push_url = ?,
|
||||
push_token = ?,
|
||||
push_chat_id = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
|
|
@ -201,6 +231,11 @@ router.patch('/settings', (req, res) => {
|
|||
boolVal(notify_due, current.notify_due),
|
||||
boolVal(notify_overdue, current.notify_overdue),
|
||||
boolVal(notify_amount_change, current.notify_amount_change),
|
||||
boolVal(notify_push_enabled, current.notify_push_enabled),
|
||||
push_channel !== undefined ? (push_channel || null) : current.push_channel,
|
||||
encryptOrNull(push_url, current.push_url),
|
||||
encryptOrNull(push_token, current.push_token),
|
||||
push_chat_id !== undefined ? (push_chat_id?.trim() || null) : current.push_chat_id,
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,99 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const { getDb, getSetting } = require('../db/database');
|
||||
const { decryptSecret } = require('./encryptionService');
|
||||
const { decryptSecret, encryptSecret } = require('./encryptionService');
|
||||
const {
|
||||
markNotificationError,
|
||||
markNotificationSuccess,
|
||||
markNotificationTestSuccess,
|
||||
} = require('./statusRuntime');
|
||||
|
||||
// ── 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, encryptSecret };
|
||||
|
||||
// ── SMTP transport ────────────────────────────────────────────────────────────
|
||||
|
||||
function getSmtpPassword() {
|
||||
|
|
@ -176,9 +263,17 @@ function recordNotification(db, billId, userId, year, month, type, date) {
|
|||
async function runNotifications() {
|
||||
const db = getDb();
|
||||
|
||||
if (getSetting('notify_smtp_enabled') !== 'true') return;
|
||||
if (!getSetting('notify_smtp_host')) return;
|
||||
if (!getSetting('notify_sender_address')) return;
|
||||
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();
|
||||
|
|
@ -199,7 +294,7 @@ async function runNotifications() {
|
|||
|
||||
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 != ''"
|
||||
"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) {
|
||||
|
|
@ -263,14 +358,33 @@ async function runNotifications() {
|
|||
|
||||
const meta = TYPE_META[type];
|
||||
const subject = meta.subject(bill);
|
||||
const html = buildEmailHtml(bill, type, dueDate);
|
||||
const urgency = meta.urgency;
|
||||
const amount = '$' + Number(bill.expected_amount || 0).toFixed(2);
|
||||
const pushBody = `${subject} · ${amount}`;
|
||||
let sent = false;
|
||||
|
||||
try {
|
||||
await sendEmail(recipient.notification_email, subject, html);
|
||||
recordNotification(db, bill.id, recipient.id, year, month, type, today);
|
||||
} catch (err) {
|
||||
errors.push(`${recipient.notification_email}/${bill.name}: ${err.message}`);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue