diff --git a/client/api.js b/client/api.js index 16d63f9..bcdde8b 100644 --- a/client/api.js +++ b/client/api.js @@ -178,6 +178,8 @@ export const api = { createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data), updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data), deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`), + driftReport: () => get('/bills/drift-report'), + snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}), billTemplates: () => get('/bills/templates'), saveBillTemplate: (data) => post('/bills/templates', data), deleteBillTemplate: (id) => del(`/bills/templates/${id}`), diff --git a/client/components/tracker/DriftInsightPanel.jsx b/client/components/tracker/DriftInsightPanel.jsx new file mode 100644 index 0000000..a80875c --- /dev/null +++ b/client/components/tracker/DriftInsightPanel.jsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api.js'; +import { cn, fmt } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; + +function DriftRow({ row, refresh }) { + const [loading, setLoading] = useState(false); + + async function handleUpdate() { + setLoading(true); + const oldAmount = row.expected_amount; + try { + await api.updateBill(row.id, { expected_amount: row.recent_amount }); + toast.success(`"${row.name}" updated to ${fmt(row.recent_amount)}`, { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.updateBill(row.id, { expected_amount: oldAmount }); + toast.success('Reverted'); + refresh(); + } catch { + toast.error('Failed to revert'); + } + }, + }, + }); + refresh(); + } catch { + toast.error('Failed to update bill'); + } finally { + setLoading(false); + } + } + + async function handleDismiss() { + setLoading(true); + try { + await api.snoozeBillDrift(row.id); + toast.success('Hidden for 30 days'); + refresh(); + } catch { + toast.error('Failed to dismiss'); + } finally { + setLoading(false); + } + } + + const isUp = row.direction === 'up'; + const sign = isUp ? '+' : ''; + + return ( +
+ {/* Bill info */} +
+
+ {row.name} + {row.category_name && ( + + {row.category_name} + + )} +
+
+ {isUp + ? + : + } + + {sign}{row.drift_pct}% over {row.months_sampled} months + +
+
+ + {/* Amount change */} +
+ + {fmt(row.expected_amount)} + + + + {fmt(row.recent_amount)} + + + {sign}{row.drift_pct}% + +
+ + {/* Actions */} +
+ + +
+
+ ); +} + +export default function DriftInsightPanel({ driftBills, refresh }) { + const [isOpen, setIsOpen] = useState(true); + + if (!driftBills?.length) return null; + + const totalNetDelta = driftBills.reduce((sum, b) => sum + (b.recent_amount - b.expected_amount), 0); + const sign = totalNetDelta >= 0 ? '+' : ''; + const netColor = totalNetDelta >= 0 ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400'; + const hasIncrease = driftBills.some(b => b.direction === 'up'); + + return ( + +
+ + {/* Header */} + + + + + {/* Bill rows */} + +
+ {driftBills.map(row => ( + + ))} +
+
+
+
+ ); +} diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.js index 7fe9777..a980b31 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.js @@ -40,4 +40,13 @@ export function useOverdueCount() { refetchInterval: 1000 * 60 * 5, // poll every 5 minutes refetchIntervalInBackground: false, // only when tab is active }); -} \ No newline at end of file +} +// Drift / price-change report — refreshed on demand, not auto-polled +export function useDriftReport() { + return useQuery({ + queryKey: ['drift-report'], + queryFn: () => api.driftReport(), + staleTime: 1000 * 60 * 10, + refetchOnWindowFocus: false, + }); +} diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index a6d703f..62d6a2f 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -339,6 +339,7 @@ function NotificationPreferences({ settings, onSaved }) { notify_1_day: !!(form.notify_1_day ?? form.notify_1d), notify_due: !!(form.notify_due ?? form.notify_day_of), notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue), + notify_amount_change: !!(form.notify_amount_change ?? true), }; payload.enabled = payload.notifications_enabled; payload.notify_3d = payload.notify_3_day; @@ -372,6 +373,7 @@ function NotificationPreferences({ settings, onSaved }) { set('notify_1_day', v)} disabled={!payload.notifications_enabled} /> set('notify_due', v)} disabled={!payload.notifications_enabled} /> set('notify_overdue', v)} disabled={!payload.notifications_enabled} /> + set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
diff --git a/client/pages/SettingsPage.jsx b/client/pages/SettingsPage.jsx index 7332311..5706d89 100644 --- a/client/pages/SettingsPage.jsx +++ b/client/pages/SettingsPage.jsx @@ -215,6 +215,7 @@ export default function SettingsPage() { currency: 'USD', date_format: 'MM/DD/YYYY', grace_period_days: 3, + drift_threshold_pct: '5', }; const [settings, setSettings] = useState(DEFAULTS); @@ -242,6 +243,7 @@ export default function SettingsPage() { currency: settings.currency, date_format: settings.date_format, grace_period_days: settings.grace_period_days, + drift_threshold_pct: settings.drift_threshold_pct, }); toast.success('Settings saved.'); } catch (err) { @@ -337,6 +339,23 @@ export default function SettingsPage() { days
+ +
+ set('drift_threshold_pct', e.target.value)} + className="w-20 font-mono" + /> + % +
+
diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index ccab575..d3fe7ee 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; -import { useTracker } from '@/hooks/useQueries'; +import { useTracker, useDriftReport } from '@/hooks/useQueries'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; @@ -29,6 +29,7 @@ import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog'; import PaymentModal from '@/components/tracker/PaymentModal'; import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter'; +import DriftInsightPanel from '@/components/tracker/DriftInsightPanel'; const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', @@ -1830,6 +1831,7 @@ export default function TrackerPage() { // Use React Query for data fetching const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month); + const { data: driftData, refetch: refetchDrift } = useDriftReport(); useEffect(() => { const querySearch = searchParams.get('search') || ''; @@ -2068,6 +2070,14 @@ export default function TrackerPage() { /> )} + {/* ── Drift / Price-Change Insights ── */} + {!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && ( + { refetch(); refetchDrift(); }} + /> + )} + {/* ── Fetch error state ── */} {isError && (
diff --git a/db/database.js b/db/database.js index 02d1df9..34d8d08 100644 --- a/db/database.js +++ b/db/database.js @@ -38,7 +38,7 @@ const DEFAULT_CATEGORIES = [ const COLUMN_WHITELIST = new Set([ // users table columns 'active', 'is_default_admin', 'notification_email', 'notifications_enabled', - 'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue', + 'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue', 'notify_amount_change', 'display_name', 'last_password_change_at', 'auth_provider', 'external_subject', 'email', 'last_login_at', // payments table columns @@ -49,7 +49,7 @@ const COLUMN_WHITELIST = new Set([ 'history_visibility', 'interest_rate', 'user_id', 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', 'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before', - 'subscription_source', 'subscription_detected_at', 'deleted_at', + 'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until', // sessions table columns 'created_at', // financial_accounts table columns @@ -2425,6 +2425,15 @@ function runMigrations() { run: function() { db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT'); } + }, + { + version: 'v0.71', + description: 'bills: add drift_snoozed_until; users: add notify_amount_change', + dependsOn: ['v0.70'], + run: function() { + db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT'); + db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1'); + } } ]; diff --git a/db/schema.sql b/db/schema.sql index 9641315..8440757 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS bills ( subscription_detected_at TEXT, deleted_at TEXT, notes TEXT, + drift_snoozed_until TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); @@ -76,6 +77,7 @@ CREATE TABLE IF NOT EXISTS users ( must_change_password INTEGER NOT NULL DEFAULT 0, first_login INTEGER NOT NULL DEFAULT 1, snowball_extra_payment REAL NOT NULL DEFAULT 0, + notify_amount_change INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); diff --git a/routes/bills.js b/routes/bills.js index cab8931..575ddbe 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -44,6 +44,31 @@ router.get('/audit', (req, res) => { res.json(auditBillsForUser(db, req.user.id, includeInactive)); }); +// ── GET /api/bills/drift-report ────────────────────────────────────────────── +router.get('/drift-report', (req, res) => { + const { getDriftReport } = require('../services/driftService'); + try { + res.json(getDriftReport(req.user.id)); + } catch (err) { + res.status(500).json({ error: 'Failed to compute drift report' }); + } +}); + +// ── POST /api/bills/:id/snooze-drift ───────────────────────────────────────── +// Registered early (before /:id) but path has suffix so no conflict +router.post('/:id/snooze-drift', (req, res) => { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const bill = db.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL').get(id); + if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' }); + const until = new Date(); + until.setDate(until.getDate() + 30); + const untilStr = until.toISOString().slice(0, 10); + db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id); + res.json({ ok: true, drift_snoozed_until: untilStr }); +}); + // ── GET /api/bills/templates ───────────────────────────────────────────────── router.get('/templates', (req, res) => { const db = getDb(); diff --git a/routes/notifications.js b/routes/notifications.js index a55ba00..c1e457f 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -58,19 +58,20 @@ router.get('/me', requireAuth, requireUser, (req, res) => { const db = getDb(); const user = db.prepare(` SELECT notification_email, notifications_enabled, - notify_3d, notify_1d, notify_due, notify_overdue + notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change FROM users WHERE id = ? `).get(req.user.id); res.json({ - smtp_enabled: getSetting('notify_smtp_enabled') === 'true', - allow_user_config: getSetting('notify_allow_user_config') === 'true', - notification_email: user.notification_email || '', + smtp_enabled: getSetting('notify_smtp_enabled') === 'true', + allow_user_config: getSetting('notify_allow_user_config') === 'true', + notification_email: user.notification_email || '', notifications_enabled: !!user.notifications_enabled, - notify_3d: !!user.notify_3d, - notify_1d: !!user.notify_1d, - notify_due: !!user.notify_due, - notify_overdue: !!user.notify_overdue, + notify_3d: !!user.notify_3d, + notify_1d: !!user.notify_1d, + notify_due: !!user.notify_due, + notify_overdue: !!user.notify_overdue, + notify_amount_change: user.notify_amount_change !== 0, }); }); @@ -79,7 +80,7 @@ router.put('/me', requireAuth, requireUser, (req, res) => { const db = getDb(); const { notification_email, notifications_enabled, - notify_3d, notify_1d, notify_due, notify_overdue, + notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change, } = req.body; db.prepare(` @@ -90,15 +91,17 @@ router.put('/me', requireAuth, requireUser, (req, res) => { notify_1d = ?, notify_due = ?, notify_overdue = ?, + notify_amount_change = ?, updated_at = datetime('now') WHERE id = ? `).run( - notification_email || null, - notifications_enabled ? 1 : 0, - notify_3d !== false ? 1 : 0, - notify_1d !== false ? 1 : 0, - notify_due !== false ? 1 : 0, + notification_email || null, + notifications_enabled ? 1 : 0, + notify_3d !== false ? 1 : 0, + notify_1d !== false ? 1 : 0, + notify_due !== false ? 1 : 0, notify_overdue !== false ? 1 : 0, + notify_amount_change !== false ? 1 : 0, req.user.id, ); diff --git a/routes/profile.js b/routes/profile.js index 18f10ce..e3a1785 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -131,7 +131,7 @@ 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_3d, notify_1d, notify_due, notify_overdue, notify_amount_change FROM users WHERE id = ? `).get(req.user.id); @@ -144,6 +144,7 @@ router.get('/settings', (req, res) => { notify_1d: !!user.notify_1d, notify_due: !!user.notify_due, notify_overdue: !!user.notify_overdue, + notify_amount_change: user.notify_amount_change !== 0, }); }); @@ -154,7 +155,7 @@ router.patch('/settings', (req, res) => { const db = getDb(); const { notification_email, email, notifications_enabled, - notify_3d, notify_1d, notify_due, notify_overdue, + notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change, } = req.body; const nextEmail = notification_email !== undefined ? notification_email : email; @@ -170,7 +171,7 @@ router.patch('/settings', (req, res) => { const current = db.prepare(` SELECT notification_email, notifications_enabled, - notify_3d, notify_1d, notify_due, notify_overdue + notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change FROM users WHERE id = ? `).get(req.user.id); @@ -189,15 +190,17 @@ router.patch('/settings', (req, res) => { notify_1d = ?, notify_due = ?, notify_overdue = ?, + notify_amount_change = ?, updated_at = datetime('now') WHERE id = ? `).run( emailVal, - boolVal(notifications_enabled, current.notifications_enabled), - boolVal(notify_3d, current.notify_3d), - boolVal(notify_1d, current.notify_1d), - boolVal(notify_due, current.notify_due), - boolVal(notify_overdue, current.notify_overdue), + boolVal(notifications_enabled, current.notifications_enabled), + boolVal(notify_3d, current.notify_3d), + boolVal(notify_1d, current.notify_1d), + boolVal(notify_due, current.notify_due), + boolVal(notify_overdue, current.notify_overdue), + boolVal(notify_amount_change, current.notify_amount_change), req.user.id, ); diff --git a/services/driftService.js b/services/driftService.js new file mode 100644 index 0000000..98c88d3 --- /dev/null +++ b/services/driftService.js @@ -0,0 +1,106 @@ +'use strict'; + +const { getDb } = require('../db/database'); +const { getCycleRange } = require('./statusService'); +const { getUserSettings } = require('./userSettings'); + +const MONTHS_BACK = 3; +const MIN_PAID_MONTHS = 2; +const MIN_ABS_DELTA = 1.00; + +function median(arr) { + if (!arr.length) return 0; + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function monthEnd(year, month) { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +function getDriftReport(userId, now = new Date()) { + try { + const db = getDb(); + const settings = getUserSettings(userId); + const thresholdPct = Math.max(1, Math.min(25, + parseFloat(settings.drift_threshold_pct ?? '5') || 5 + )); + + const bills = db.prepare(` + SELECT b.*, c.name AS category_name + FROM bills b + LEFT JOIN categories c ON c.id = b.category_id + WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL + `).all(userId); + + const todayStr = now.toISOString().slice(0, 10); + const drifted = []; + + const mbsStmt = db.prepare( + 'SELECT is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?' + ); + const payStmt = db.prepare(` + SELECT COALESCE(SUM(amount), 0) AS total + FROM payments + WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL + `); + + for (const bill of bills) { + if (!bill.expected_amount || bill.expected_amount <= 0) continue; + if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue; + + const monthTotals = []; + + for (let i = 1; i <= MONTHS_BACK; i++) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); + const yr = d.getUTCFullYear(); + const mo = d.getUTCMonth() + 1; + + // Skip if bill was created after this month ended + const monthEndStr = `${yr}-${String(mo).padStart(2,'0')}-${String(monthEnd(yr, mo)).padStart(2,'0')}`; + if (bill.created_at && bill.created_at.slice(0, 10) > monthEndStr) continue; + + const mbs = mbsStmt.get(bill.id, yr, mo); + if (mbs?.is_skipped) continue; + + const range = getCycleRange(yr, mo, bill); + if (!range) continue; + + const { total } = payStmt.get(bill.id, range.start, range.end); + if (total > 0) monthTotals.push(total); + } + + if (monthTotals.length < MIN_PAID_MONTHS) continue; + + const recentAmount = median(monthTotals); + const delta = recentAmount - bill.expected_amount; + const absDelta = Math.abs(delta); + const driftPct = (delta / bill.expected_amount) * 100; + + if (absDelta < MIN_ABS_DELTA) continue; + if (Math.abs(driftPct) < thresholdPct) continue; + + drifted.push({ + id: bill.id, + name: bill.name, + category_name: bill.category_name ?? null, + expected_amount: bill.expected_amount, + recent_amount: Math.round(recentAmount * 100) / 100, + drift_pct: Math.round(driftPct * 10) / 10, + direction: delta > 0 ? 'up' : 'down', + months_sampled: monthTotals.length, + drift_snoozed_until: bill.drift_snoozed_until ?? null, + }); + } + + return { bills: drifted, threshold_pct: thresholdPct }; + } catch (err) { + console.error('[driftService] getDriftReport error:', err.message); + return { bills: [], threshold_pct: 5, error: err.message }; + } +} + +module.exports = { getDriftReport }; diff --git a/services/notificationService.js b/services/notificationService.js index e061254..266f82a 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -272,4 +272,123 @@ async function runNotifications() { } } -module.exports = { runNotifications, sendTestEmail, createTransport }; +// ── 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 ` + + ${esc(b.name)} + ${fmtAmt(b.expected_amount)} + ${fmtAmt(b.recent_amount)} + ${arrow} ${sign}${b.drift_pct}% + `; + }).join(''); + + return ` + + + + +
+ + + + + + + + + + + + + +
+

Bill Tracker

+

Price Changes Detected

+
+

+ The following bills have been paid at amounts different from their expected amounts for the past ${bills[0]?.months_sampled ?? 2}+ months. +

+
+ + + + + + + + + + ${rows} +
BillWasNow ~Change
+
+

+ You can update the expected amounts or dismiss these alerts in Bill Tracker. +

+
+
+ +`; +} + +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 = now.toISOString().slice(0, 10); + + 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 }; diff --git a/services/userSettings.js b/services/userSettings.js index e8475e3..af0d6a3 100644 --- a/services/userSettings.js +++ b/services/userSettings.js @@ -7,6 +7,7 @@ const USER_SETTING_KEYS = [ 'date_format', 'grace_period_days', 'notify_days_before', + 'drift_threshold_pct', ]; function defaultUserSettings() { diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index 40a85ef..44c7cab 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -2,7 +2,7 @@ const cron = require('node-cron'); const { getDb, getSetting } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { pruneExpiredSessions } = require('../services/authService'); -const { runNotifications } = require('../services/notificationService'); +const { runNotifications, runDriftNotifications } = require('../services/notificationService'); const { runAllCleanup } = require('../services/cleanupService'); const { markWorkerError, @@ -49,6 +49,9 @@ async function runDailyTasks() { pruneExpiredSessions(); await runNotifications(); + await runDriftNotifications().catch(err => { + console.error('[worker] Drift notification error (non-fatal):', err.message); + }); // Run scheduled cleanup tasks (expired import sessions, stale temp files, etc.) await runAllCleanup().catch(err => {