'use strict'; import type { Db } from '../types/db'; const cron = require('node-cron'); const { log } = require('../utils/logger.cts'); 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 (0–23) 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 { 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) { log.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); } 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) { log.error(`[worker] Failed to mark autopay for bill ${bill.id}:`, err.message); } } } } pruneExpiredSessions(); pruneWebAuthnChallenges(db); await runNotifications().catch((err: any) => { log.error('[worker] Notification error (non-fatal):', err.message); }); await runDriftNotifications().catch((err: any) => { log.error('[worker] Drift notification error (non-fatal):', err.message); }); await runAllCleanup().catch((err: any) => { log.error('[worker] Cleanup error (non-fatal):', err.message); }); markWorkerSuccess(nextDailyRunIso()); log.info(`[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()); log.error('[worker] Daily task error:', err); }); }); } // Called when 'reminder_hour' changes so it applies without a restart. function rescheduleDailyWorker(): void { try { scheduleDaily(); markWorkerStarted(nextDailyRunIso()); log.info(`[worker] Rescheduled daily tasks to ${resolveReminderHour()}:00`); } catch (err: any) { log.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()); log.error('[worker] Startup task error:', err); }); // Run every day at the configured hour (default 6 AM) scheduleDaily(); } module.exports = { start, runDailyTasks, nextDailyRunIso, rescheduleDailyWorker };