BillTracker/workers/dailyWorker.cts

159 lines
5.1 KiB
TypeScript
Raw Normal View History

'use strict';
import type { Db } from '../types/db';
2026-05-03 19:51:57 -05:00
const cron = require('node-cron');
const { getDb, getSetting } = require('../db/database.cts');
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');
2026-05-03 19:51:57 -05:00
const {
markWorkerError,
markWorkerStarted,
markWorkerSuccess,
} = require('../services/statusRuntime.cts');
const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts');
2026-05-03 19:51:57 -05:00
// The hour (023) the single daily job runs — configurable via 'reminder_hour'
// (default 6 AM). One source of truth for both the cron expression and next-run.
function resolveReminderHour(): number {
const parsed = parseInt(getSetting('reminder_hour') ?? '6', 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 23 ? parsed : 6;
}
2026-05-03 19:51:57 -05:00
function nextDailyRunIso(from: Date = new Date()): string {
2026-05-03 19:51:57 -05:00
const next = new Date(from);
next.setHours(resolveReminderHour(), 0, 0, 0);
2026-05-03 19:51:57 -05:00
if (next <= from) next.setDate(next.getDate() + 1);
return next.toISOString();
}
async function runDailyTasks(): Promise<void> {
const db: Db = getDb();
2026-05-03 19:51:57 -05:00
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const todayStr = localDateString(now);
2026-05-03 19:51:57 -05:00
const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all();
if (bills.length > 0) {
const billIds = bills.map((b: any) => b.id);
const placeholders = billIds.map(() => '?').join(',');
const windowStart = localDateStringDaysAgo(90, now);
let allPayments: any[] = [];
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: any) {
console.error('[worker] Failed to batch-fetch payments:', err.message);
}
2026-05-03 19:51:57 -05:00
// Group payments by bill_id in memory
const paymentsByBill = new Map<any, any>();
for (const p of allPayments) {
if (!paymentsByBill.has(p.bill_id)) paymentsByBill.set(p.bill_id, []);
paymentsByBill.get(p.bill_id).push(p);
}
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
const payments = (paymentsByBill.get(bill.id) || []).filter(
(p: any) => 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: any) {
console.error(`[worker] Failed to mark autopay for bill ${bill.id}:`, err.message);
}
}
2026-05-03 19:51:57 -05:00
}
}
pruneExpiredSessions();
pruneWebAuthnChallenges(db);
await runNotifications().catch((err: any) => {
console.error('[worker] Notification error (non-fatal):', err.message);
});
await runDriftNotifications().catch((err: any) => {
console.error('[worker] Drift notification error (non-fatal):', err.message);
});
2026-05-03 19:51:57 -05:00
await runAllCleanup().catch((err: any) => {
2026-05-03 19:51:57 -05:00
console.error('[worker] Cleanup error (non-fatal):', err.message);
});
markWorkerSuccess(nextDailyRunIso());
console.log(`[worker] Daily tasks ran at ${todayStr}`);
}
let scheduledTask: any = null;
// (Re)create the daily cron at the configured hour. Stops any prior task first.
function scheduleDaily(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
const hour = resolveReminderHour();
scheduledTask = cron.schedule(`0 ${hour} * * *`, () => {
runDailyTasks().catch((err: any) => {
markWorkerError(err, nextDailyRunIso());
console.error('[worker] Daily task error:', err);
});
});
}
// Called when 'reminder_hour' changes so it applies without a restart.
function rescheduleDailyWorker(): void {
try {
scheduleDaily();
markWorkerStarted(nextDailyRunIso());
console.log(`[worker] Rescheduled daily tasks to ${resolveReminderHour()}:00`);
} catch (err: any) {
console.error('[worker] Failed to reschedule daily worker:', err.message);
}
}
function start(): void {
2026-05-03 19:51:57 -05:00
markWorkerStarted(nextDailyRunIso());
// Run once at startup
runDailyTasks().catch((err: any) => {
2026-05-03 19:51:57 -05:00
markWorkerError(err, nextDailyRunIso());
console.error('[worker] Startup task error:', err);
});
// Run every day at the configured hour (default 6 AM)
scheduleDaily();
2026-05-03 19:51:57 -05:00
}
module.exports = { start, runDailyTasks, nextDailyRunIso, rescheduleDailyWorker };