BillTracker/workers/dailyWorker.js

165 lines
5.6 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.

'use strict';
const cron = require('node-cron');
const { getDb, getSetting } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts');
const { pruneExpiredSessions } = require('../services/authService.cts');
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService.cts');
const { runNotifications, runDriftNotifications } = require('../services/notificationService.cts');
const { runAllCleanup } = require('../services/cleanupService.cts');
const {
markWorkerError,
markWorkerStarted,
markWorkerSuccess,
} = require('../services/statusRuntime.cts');
const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts');
// The hour (023) the single daily job runs — configurable via the global
// 'reminder_hour' setting, defaulting to 6 AM. One source of truth for both the
// cron expression and the next-run display.
function resolveReminderHour() {
const parsed = parseInt(getSetting('reminder_hour') ?? '6', 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 23 ? parsed : 6;
}
function nextDailyRunIso(from = new Date()) {
const next = new Date(from);
next.setHours(resolveReminderHour(), 0, 0, 0);
if (next <= from) next.setDate(next.getDate() + 1);
return next.toISOString();
}
async function runDailyTasks() {
const db = getDb();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// Local date — keep consistent with year/month above and with the client's
// notion of "today". toISOString() would give the UTC date, which can be a
// different calendar day and caused autopay marking a day early/late.
const todayStr = localDateString(now);
const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all();
if (bills.length > 0) {
// Batch-fetch payments for all active bills from the last 90 days to avoid
// one query per bill. Different billing cycles (monthly/quarterly/annual) all
// fit inside a 90-day window for the current month's due-date checks.
const billIds = bills.map(b => b.id);
const placeholders = billIds.map(() => '?').join(',');
const windowStart = localDateStringDaysAgo(90, now);
let allPayments = [];
try {
allPayments = db.prepare(`
SELECT * FROM payments
WHERE bill_id IN (${placeholders})
AND paid_date >= ?
AND deleted_at IS NULL
`).all(...billIds, windowStart);
} catch (err) {
console.error('[worker] Failed to batch-fetch payments:', err.message);
}
// Group payments by bill_id in memory
const paymentsByBill = new Map();
for (const p of allPayments) {
if (!paymentsByBill.has(p.bill_id)) paymentsByBill.set(p.bill_id, []);
paymentsByBill.get(p.bill_id).push(p);
}
// Prepare autopay update statement once outside the loop
const markAutopay = db.prepare(
"UPDATE bills SET autodraft_status = 'assumed_paid', updated_at = datetime('now') WHERE id = ?"
);
for (const bill of bills) {
const range = getCycleRange(year, month, bill);
if (!range) continue; // bill does not apply this cycle
// Filter pre-fetched payments to the bill's cycle range
const payments = (paymentsByBill.get(bill.id) || []).filter(
p => p.paid_date >= range.start && p.paid_date <= range.end
);
const row = buildTrackerRow(bill, payments, year, month, todayStr);
if (!row) continue;
// Auto-mark autopay bills as assumed_paid on due date
if (
bill.autopay_enabled &&
bill.autodraft_status === 'pending' &&
todayStr >= row.due_date
) {
try {
markAutopay.run(bill.id);
} catch (err) {
console.error(`[worker] Failed to mark autopay for bill ${bill.id}:`, err.message);
}
}
}
}
pruneExpiredSessions();
pruneWebAuthnChallenges(db);
await runNotifications().catch(err => {
console.error('[worker] Notification error (non-fatal):', err.message);
});
await runDriftNotifications().catch(err => {
console.error('[worker] Drift notification error (non-fatal):', err.message);
});
await runAllCleanup().catch(err => {
console.error('[worker] Cleanup error (non-fatal):', err.message);
});
markWorkerSuccess(nextDailyRunIso());
console.log(`[worker] Daily tasks ran at ${todayStr}`);
}
let scheduledTask = null;
// (Re)create the daily cron at the configured hour. Stops any prior task first.
function scheduleDaily() {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
const hour = resolveReminderHour();
scheduledTask = cron.schedule(`0 ${hour} * * *`, () => {
runDailyTasks().catch(err => {
markWorkerError(err, nextDailyRunIso());
console.error('[worker] Daily task error:', err);
});
});
}
// Called when the 'reminder_hour' setting changes so it applies without a
// restart. Defensive: a failure here must never take the worker down.
function rescheduleDailyWorker() {
try {
scheduleDaily();
markWorkerStarted(nextDailyRunIso());
console.log(`[worker] Rescheduled daily tasks to ${resolveReminderHour()}:00`);
} catch (err) {
console.error('[worker] Failed to reschedule daily worker:', err.message);
}
}
function start() {
markWorkerStarted(nextDailyRunIso());
// Run once at startup
runDailyTasks().catch(err => {
markWorkerError(err, nextDailyRunIso());
console.error('[worker] Startup task error:', err);
});
// Run every day at the configured hour (default 6 AM)
scheduleDaily();
}
module.exports = { start, runDailyTasks, nextDailyRunIso, rescheduleDailyWorker };