165 lines
5.6 KiB
JavaScript
165 lines
5.6 KiB
JavaScript
'use strict';
|
||
|
||
const cron = require('node-cron');
|
||
const { getDb, getSetting } = require('../db/database');
|
||
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
||
const { pruneExpiredSessions } = require('../services/authService');
|
||
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService');
|
||
const { runNotifications, runDriftNotifications } = require('../services/notificationService');
|
||
const { runAllCleanup } = require('../services/cleanupService');
|
||
const {
|
||
markWorkerError,
|
||
markWorkerStarted,
|
||
markWorkerSuccess,
|
||
} = require('../services/statusRuntime');
|
||
const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts');
|
||
|
||
// The hour (0–23) 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 };
|