feat(maintenance): reminders CLI + systemd timer drop-in
Phase 3-4 of maintenance reminders. scripts/maintenance-reminders.ts: thin CLI that opens the same pg pool as the app and calls runRemindersOnce. Flags: --soon-days N (default 7), --company <id> (default all), --dry-run (count without firing), --backfill (mark all currently due as already-sent, mutually exclusive with --dry-run). Prints a single-line JSON summary so journald/jq handles it cleanly. package.json: + reminders:check script. DEPLOYMENT.md: documents the systemd .service + .timer pair for 06:00 daily, with Persistent=true so a missed run during host downtime still fires on next boot. Includes the first-deploy protocol (--dry-run to scope, then either let day-one alert or --backfill for a clean slate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -190,6 +190,66 @@ systemctl status buildfor_life_ops
|
|||||||
journalctl -u buildfor_life_ops -f
|
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)
|
## 11. Reverse proxy (nginx)
|
||||||
|
|
||||||
`/etc/nginx/sites-available/buildfor_life_ops`:
|
`/etc/nginx/sites-available/buildfor_life_ops`:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx scripts/seed/system-asset-types.ts",
|
"db:seed": "tsx scripts/seed/system-asset-types.ts",
|
||||||
"create-user": "tsx scripts/create-user.ts",
|
"create-user": "tsx scripts/create-user.ts",
|
||||||
|
"reminders:check": "tsx scripts/maintenance-reminders.ts",
|
||||||
"prepare": "husky || true"
|
"prepare": "husky || true"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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(() => {});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user