diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c54568b..6d0eebf 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -190,6 +190,66 @@ systemctl status buildfor_life_ops journalctl -u buildfor_life_ops -f ``` +### Maintenance reminders timer + +The app records `next_due_at` on every time-based maintenance schedule but does not poll itself. A daily systemd timer runs `pnpm run reminders:check`, which scans for schedules entering the 7-day warning window or already overdue and fans out via the existing in-app + email + Matrix notifier. Re-runs are idempotent — `maintenance_reminders_sent` deduplicates per `(schedule, kind, due_at)`. + +`/etc/systemd/system/buildfor_life_ops-reminders.service`: + +```ini +[Unit] +Description=buildfor_life_ops maintenance reminder cron +After=postgresql.service network.target +Wants=postgresql.service + +[Service] +Type=oneshot +User=ops +Group=ops +WorkingDirectory=/home/ops/buildfor_life_ops +EnvironmentFile=/home/ops/buildfor_life_ops/.env +Environment=NODE_ENV=production +ExecStart=/home/ops/.local/share/fnm/aliases/default/bin/pnpm run reminders:check +``` + +`/etc/systemd/system/buildfor_life_ops-reminders.timer`: + +```ini +[Unit] +Description=Run buildfor_life_ops maintenance reminders daily + +[Timer] +OnCalendar=*-*-* 06:00:00 +Persistent=true +Unit=buildfor_life_ops-reminders.service + +[Install] +WantedBy=timers.target +``` + +Enable: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now buildfor_life_ops-reminders.timer +sudo systemctl list-timers buildfor_life_ops-reminders.timer +``` + +**First-run protocol** to avoid a deluge of stale alerts on day one: + +```bash +# Inspect what would fire without notifying. +sudo -iu ops bash -lc 'cd ~/buildfor_life_ops && pnpm run reminders:check -- --dry-run' + +# If the count is reasonable, run normally — the timer will pick up subsequent +# windows automatically. Or, if you want a clean slate, mark everything +# currently-due as already-notified (no fan-out), so day-one alerts only +# new breaches: +sudo -iu ops bash -lc 'cd ~/buildfor_life_ops && pnpm run reminders:check -- --backfill' +``` + +Logs end up in `journalctl -u buildfor_life_ops-reminders.service`. Each run prints a single JSON line (`{ ok, scanned, fired, skippedDedup, noRecipients, ... }`) so `journalctl --output=cat | grep '"ok":true' | jq` gives a clean trend view. + ## 11. Reverse proxy (nginx) `/etc/nginx/sites-available/buildfor_life_ops`: diff --git a/package.json b/package.json index 475e3c7..f02c9dd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:studio": "drizzle-kit studio", "db:seed": "tsx scripts/seed/system-asset-types.ts", "create-user": "tsx scripts/create-user.ts", + "reminders:check": "tsx scripts/maintenance-reminders.ts", "prepare": "husky || true" }, "dependencies": { diff --git a/scripts/maintenance-reminders.ts b/scripts/maintenance-reminders.ts new file mode 100644 index 0000000..bd05a0b --- /dev/null +++ b/scripts/maintenance-reminders.ts @@ -0,0 +1,71 @@ +import 'dotenv/config'; +import { pool } from '../src/lib/server/db/client'; +import { + runRemindersOnce, + type RunResult +} from '../src/lib/server/services/maintenance-reminders'; + +function stripSurroundingQuotes(s: string | undefined): string | undefined { + if (!s || s.length < 2) return s; + const first = s[0]; + const last = s[s.length - 1]; + if ((first === "'" && last === "'") || (first === '"' && last === '"')) { + return s.slice(1, -1); + } + return s; +} + +function readArg(flag: string, fallback?: string): string | undefined { + const i = process.argv.indexOf(flag); + return stripSurroundingQuotes(i >= 0 ? process.argv[i + 1] : fallback); +} + +function hasFlag(flag: string): boolean { + return process.argv.includes(flag); +} + +async function main(): Promise { + const soonRaw = readArg('--soon-days', '7') ?? '7'; + const soonDays = Number.parseInt(soonRaw, 10); + if (!Number.isFinite(soonDays) || soonDays < 0 || soonDays > 365) { + console.error('--soon-days must be an integer in [0, 365]'); + process.exit(2); + } + const companyId = readArg('--company'); + const dryRun = hasFlag('--dry-run'); + const backfill = hasFlag('--backfill'); + + if (dryRun && backfill) { + console.error('--dry-run and --backfill are mutually exclusive'); + process.exit(2); + } + + const startedAt = new Date(); + let result: RunResult; + try { + result = await runRemindersOnce({ companyId, soonDays, dryRun, backfill }); + } catch (e) { + console.error(JSON.stringify({ ok: false, error: (e as Error).message })); + process.exit(1); + } + + const out = { + ok: true, + startedAt: startedAt.toISOString(), + durationMs: Date.now() - startedAt.getTime(), + soonDays, + companyId: companyId ?? null, + ...result + }; + // Single-line JSON so journald/grep handle it cleanly. + console.log(JSON.stringify(out)); +} + +main() + .catch((e) => { + console.error(JSON.stringify({ ok: false, error: (e as Error).message })); + process.exitCode = 1; + }) + .finally(async () => { + await pool.end().catch(() => {}); + });