/** * Standalone cron worker for AtomCMS-Next. * * Replaces Laravel's scheduler (app/Console/Kernel.php + queue:work) with a * single long-running process driven by `croner`. Run it OUTSIDE the Next.js * request lifecycle (e.g. a separate container / pm2 process): * * pnpm jobs:worker # -> tsx scripts/jobs-worker.ts * * Each job body is wrapped in its own try/catch so one failing tick never * tears down the scheduler; a thrown error or a falsy result is logged and the * worker keeps ticking. Croner runs ticks serially per-job (protect: true) so a * slow run can't overlap itself. * * DEPENDENCIES THIS FILE EXPECTS (orchestrator must provide them — noted here * so the build isn't silently broken): * - `croner` package (not yet in package.json): `pnpm add croner`. * - `@/lib/services/alert` exporting `alert.emulatorOffline()` — a thin * notifier (email/Discord) for emulator-down events. Not present yet; this * worker is the first consumer. */ import "dotenv/config"; import { Cron } from "croner"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { emulatorOffline } from "@/lib/services/alert"; // --- small logging helper (timestamped, namespaced) ------------------------ function log(scope: string, msg: string, ...rest: unknown[]): void { console.log(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, ...rest); } function logErr(scope: string, msg: string, e: unknown): void { console.error(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, e instanceof Error ? e.message : e); } // --- (a) emulator health ping — every minute ------------------------------- // // Our RconClient has no `isConnected`: the TCP transport is fire-and-forget and // resolves `false` on a dead/unreachable socket (it never throws for that). So // we treat BOTH a thrown error AND a falsy `send()` result as "offline" and // fire the alert. `data: null` matches the no-payload command shape AtomCMS // uses for keep-alive style commands. async function pingEmulator(): Promise { let online = false; try { online = await rcon.send("ping", null); } catch (e) { logErr("ping", "rcon.send threw", e); online = false; } if (online) { log("ping", "emulator reachable"); return; } log("ping", "emulator unreachable — raising offline alert"); try { await emulatorOffline(); } catch (e) { // Never let the alert channel failing crash the tick. logErr("ping", "emulatorOffline failed", e); } } // --- (b) maintenance:check — every minute ---------------------------------- // // website_maintenance_tasks has no scheduled-time column (id, userId, task, // completed, timestamps), so "scheduled maintenance" == at least one row with // completed = false. When such a task exists we flip the `maintenance_enabled` // website_setting on; otherwise we clear it. The setting value is the canonical // '1' / '0' string the rest of the CMS reads (see SiteSettings.getBool). // // If the model isn't present in the running schema (older DB), the prisma call // throws and we skip — the catch keeps the worker alive. async function maintenanceCheck(): Promise { let pending = 0; try { pending = await prisma.websiteMaintenanceTasks.count({ where: { completed: false }, }); } catch (e) { // Table absent / DB unreachable — skip this tick rather than thrashing. logErr("maintenance", "could not read website_maintenance_tasks — skipping", e); return; } const desired = pending > 0 ? "1" : "0"; try { const current = await prisma.websiteSetting.findUnique({ where: { key: "maintenance_enabled" }, select: { value: true }, }); if (current?.value === desired) { log("maintenance", `no change (maintenance_enabled=${desired}, pending=${pending})`); return; } await prisma.websiteSetting.upsert({ where: { key: "maintenance_enabled" }, update: { value: desired }, create: { key: "maintenance_enabled", value: desired, comment: "Toggled by jobs-worker" }, }); log("maintenance", `maintenance_enabled -> ${desired} (pending tasks: ${pending})`); } catch (e) { logErr("maintenance", "failed to toggle maintenance_enabled", e); } } // --- (c) bans cleanup — hourly --------------------------------------------- // // Bans expire purely by the query-time `ban_expire > now()` filter applied // wherever bans are read, so there's nothing to delete on a schedule. We keep // the hourly tick for observability (and an obvious hook if a future hard-purge // is ever wanted). async function bansCleanup(): Promise { try { const now = Math.floor(Date.now() / 1000); const active = await prisma.ban.count({ where: { banExpire: { gt: now } } }); log("bans", `cleanup is automatic via ban_expire>now filter — ${active} active ban(s), nothing to purge`); } catch (e) { logErr("bans", "count failed (non-fatal)", e); } } // --- scheduler wiring ------------------------------------------------------ const jobs: Cron[] = [ new Cron("* * * * *", { name: "emulator-ping", protect: true }, pingEmulator), new Cron("* * * * *", { name: "maintenance-check", protect: true }, maintenanceCheck), new Cron("0 * * * *", { name: "bans-cleanup", protect: true }, bansCleanup), ]; log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`); // Run once immediately on boot so we don't wait up to a minute for first signal. void pingEmulator(); void maintenanceCheck(); // --- graceful shutdown ----------------------------------------------------- async function shutdown(signal: string): Promise { log("worker", `received ${signal} — stopping jobs and disconnecting`); for (const j of jobs) j.stop(); try { await prisma.$disconnect(); } catch { // ignore — we're exiting anyway } process.exit(0); } process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGTERM", () => void shutdown("SIGTERM")); // Don't let an unexpected async throw kill the whole worker. process.on("unhandledRejection", (reason) => { logErr("worker", "unhandledRejection", reason); });