BillTracker/services/backupScheduler.cts

178 lines
5.1 KiB
TypeScript

// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';
const cron = require('node-cron');
const { log } = require('../utils/logger.cts');
const { getSetting, setSetting } = require('../db/database.cts');
const { applyScheduledRetention, createBackup } = require('./backupService.cts');
interface ScheduleSettings {
enabled: boolean;
frequency: string;
time: string;
retention_count: number;
}
let task: any = null;
let nextRunAt: string | null = null;
let running = false;
function parseBool(value: unknown): boolean {
return value === true || value === 'true';
}
function validateScheduleSettings(input: any = {}): ScheduleSettings {
const enabled = parseBool(input.enabled);
const frequency = input.frequency || 'daily';
const time = input.time || '02:00';
const retentionCount = parseInt(input.retention_count ?? '2', 10);
if (!['daily', 'weekly'].includes(frequency)) {
const err = new Error('frequency must be daily or weekly') as any;
err.status = 400;
throw err;
}
if (!/^\d{2}:\d{2}$/.test(time)) {
const err = new Error('time must use HH:MM format') as any;
err.status = 400;
throw err;
}
const [hour, minute] = time.split(':').map(Number);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
const err = new Error('time must be a valid 24-hour time') as any;
err.status = 400;
throw err;
}
if (!Number.isInteger(retentionCount) || retentionCount < 1 || retentionCount > 365) {
const err = new Error('retention_count must be between 1 and 365') as any;
err.status = 400;
throw err;
}
return { enabled, frequency, time, retention_count: retentionCount };
}
function readSettings(): ScheduleSettings {
return validateScheduleSettings({
enabled: getSetting('backup_schedule_enabled') === 'true',
frequency: getSetting('backup_schedule_frequency') || 'daily',
time: getSetting('backup_schedule_time') || '02:00',
retention_count: getSetting('backup_schedule_retention_count') || '2',
});
}
function getLastRunAt(): string | null {
return getSetting('backup_schedule_last_run_at') || null;
}
function getLastError(): string | null {
return getSetting('backup_schedule_last_error') || null;
}
function computeNextRun(
settings: ScheduleSettings = readSettings(),
from: Date = new Date(),
): string | null {
if (!settings.enabled) return null;
const [hour, minute] = settings.time.split(':').map(Number);
const next = new Date(from);
next.setHours(hour, minute, 0, 0);
if (settings.frequency === 'weekly') {
while (next <= from || next.getDay() !== 0) next.setDate(next.getDate() + 1);
return next.toISOString();
}
if (next <= from) next.setDate(next.getDate() + 1);
return next.toISOString();
}
function cronExpression(settings: ScheduleSettings): string {
const [hour, minute] = settings.time.split(':').map(Number);
return settings.frequency === 'weekly' ? `${minute} ${hour} * * 0` : `${minute} ${hour} * * *`;
}
async function runScheduledBackupNow(): Promise<any> {
if (running) {
const err = new Error('Scheduled backup is already running') as any;
err.status = 409;
throw err;
}
running = true;
try {
const settings = readSettings();
const backup = await createBackup('scheduled-backup');
applyScheduledRetention(settings.retention_count);
setSetting('backup_schedule_last_run_at', new Date().toISOString());
setSetting('backup_schedule_last_error', '');
nextRunAt = computeNextRun(settings);
return backup;
} catch (err: any) {
setSetting('backup_schedule_last_error', err.message || 'Scheduled backup failed');
throw err;
} finally {
running = false;
}
}
function stop(): void {
if (task) task.stop();
task = null;
}
function reloadSchedule() {
stop();
const settings = readSettings();
nextRunAt = computeNextRun(settings);
if (!settings.enabled) return getScheduleStatus();
task = cron.schedule(cronExpression(settings), () => {
runScheduledBackupNow().catch((err: any) => {
log.error('[backupScheduler] Scheduled backup failed:', err.message);
});
});
return getScheduleStatus();
}
function saveSettings(input: any) {
const settings = validateScheduleSettings(input);
setSetting('backup_schedule_enabled', settings.enabled ? 'true' : 'false');
setSetting('backup_schedule_frequency', settings.frequency);
setSetting('backup_schedule_time', settings.time);
setSetting('backup_schedule_retention_count', String(settings.retention_count));
return reloadSchedule();
}
function getScheduleStatus() {
const settings = readSettings();
return {
ok: !getLastError(),
enabled: settings.enabled,
running,
frequency: settings.frequency,
time: settings.time,
retention_count: settings.retention_count,
last_run_at: getLastRunAt(),
next_run_at: nextRunAt ?? computeNextRun(settings),
last_error: getLastError(),
};
}
function start() {
return reloadSchedule();
}
module.exports = {
computeNextRun,
getScheduleStatus,
reloadSchedule,
runScheduledBackupNow,
saveSettings,
start,
validateScheduleSettings,
};