/*********************************************************************************************************************************************************************** * File Name : cronRegistry.util.js * Type : Utility * Description : Shared machinery for settings-backed cron jobs (the 4 jobs * that emit notifications and are configurable from * /admin/notifications/settings). Not every cron job in the * app goes through this — jobs with no notification tied to * them (e.g. lift_expired_bans) keep using node-cron directly. * * startSettingsBackedJobs() reads each job's schedule from * cron_notification_settings (falling back to — and seeding — * the job's own hardcoded default on first boot), then keeps * a live reference to the scheduled task so it can be swapped * out later via rescheduleJob() without a server restart. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 2, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const cron = require('node-cron'); const CronNotificationSetting = require('../models/notifications/cron_notification_setting.mdl'); // job_name -> { task: ScheduledTask, run: fn } const runningTasks = new Map(); // CockroachDB can't run Sequelize's findOrCreate() — it wraps the insert in a // pg_temp PL/pgSQL function to atomically catch unique_violation, which // CockroachDB rejects ("cannot create user-defined functions under a temporary // schema"). Plain findOne-then-create sidesteps it; the race window (two boots // racing to seed the same job_name) is a non-issue here — jobs are seeded once. async function getOrCreateSetting(jobName, defaultSchedule) { let row = await CronNotificationSetting.findOne({ where: { job_name: jobName } }); if (row) return row; try { row = await CronNotificationSetting.create({ job_name: jobName, enabled: true, schedule: defaultSchedule }); } catch (err) { row = await CronNotificationSetting.findOne({ where: { job_name: jobName } }); if (!row) throw err; } return row; } async function startSettingsBackedJobs(jobs, scopeLabel) { const registered = []; for (const { name, schedule: defaultSchedule, run } of jobs) { let schedule = defaultSchedule; try { const settings = await getOrCreateSetting(name, defaultSchedule); schedule = settings.schedule || defaultSchedule; } catch (err) { console.error(`[CRON][${scopeLabel}] Failed to load settings for "${name}", using hardcoded default:`, err); } if (!cron.validate(schedule)) { console.error(`[CRON][${scopeLabel}] Invalid schedule for "${name}": "${schedule}" — skipped.`); continue; } const task = cron.schedule(schedule, run); runningTasks.set(name, { task, run }); registered.push({ name, scope: scopeLabel, schedule }); } return registered; } // Live-swap a running job's schedule — used by notificationSettings.controller.js // after an admin picks a new preset. No server restart required. function rescheduleJob(jobName, newSchedule) { const entry = runningTasks.get(jobName); if (!entry) throw new Error(`No running cron task found for "${jobName}".`); if (!cron.validate(newSchedule)) throw new Error(`Invalid cron schedule: "${newSchedule}".`); entry.task.stop(); const task = cron.schedule(newSchedule, entry.run); runningTasks.set(jobName, { task, run: entry.run }); } module.exports = { startSettingsBackedJobs, rescheduleJob, getOrCreateSetting };