BillTracker/workers/dailyWorker.cts

157 lines
5.1 KiB
TypeScript
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';
import type { Db } from '../types/db';
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');
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 '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;
}
function nextDailyRunIso(from: Date = new Date()): string {
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(): Promise<void> {
const db: Db = getDb();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const todayStr = localDateString(now);
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);
}
// 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);
}
}
}
}
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);
});
await runAllCleanup().catch((err: any) => {
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 {
markWorkerStarted(nextDailyRunIso());
// Run once at startup
runDailyTasks().catch((err: any) => {
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 };