/** * @file Express router for the rules-based alerting engine: CRUD for alert * rules, the fired-alert feed with pagination and unacked filtering, and * acknowledge endpoints. Rule evaluation itself lives in server/lib/alerts.js. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const { v4: uuidv4 } = require("uuid"); const { stmts, db } = require("../db"); const { broadcast } = require("../websocket"); const { RULE_TYPES, validateRuleConfig, invalidateRuleCache, previewRule, } = require("../lib/alerts"); const { memberId, bySessionWhere } = require("../lib/scope"); const router = Router(); function serializeRule(row) { let config = {}; try { config = JSON.parse(row.config || "{}"); } catch { /* surface as empty config rather than failing the request */ } return { ...row, config, enabled: row.enabled === 1 }; } // GET /api/alerts/rules - List all alert rules router.get("/rules", (_req, res) => { res.json({ rules: stmts.listAlertRules.all().map(serializeRule) }); }); // POST /api/alerts/rules - Create an alert rule router.post("/rules", (req, res) => { const { name, rule_type, config, enabled, cooldown_seconds } = req.body || {}; if (!name || typeof name !== "string" || !name.trim()) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: "name is required" }, }); } const validated = validateRuleConfig(rule_type, config); if (!validated.ok) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } }); } const cooldown = cooldown_seconds == null ? 300 : Number.isInteger(cooldown_seconds) ? cooldown_seconds : -1; if (cooldown < 0) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" }, }); } const id = uuidv4(); stmts.insertAlertRule.run( id, name.trim(), rule_type, JSON.stringify(validated.config), enabled === false ? 0 : 1, cooldown ); invalidateRuleCache(); res.status(201).json({ rule: serializeRule(stmts.getAlertRule.get(id)) }); }); // POST /api/alerts/rules/preview - What a rule WOULD have fired over the last // `days` (1–90, default 7) of recorded history. Read-only: nothing is saved, // fired or broadcast. router.post("/rules/preview", (req, res) => { const { rule_type, config, days, cooldown_seconds } = req.body || {}; const validated = validateRuleConfig(rule_type, config); if (!validated.ok) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } }); } const window = days == null ? 7 : Number(days); if (!Number.isInteger(window) || window < 1 || window > 90) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: "days must be an integer in 1..90" }, }); } const cooldown = cooldown_seconds == null ? 300 : cooldown_seconds; if (!Number.isInteger(cooldown) || cooldown < 0) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" }, }); } res.json(previewRule(rule_type, validated.config, { days: window, cooldownSeconds: cooldown })); }); // PATCH /api/alerts/rules/:id - Update an alert rule (partial) router.patch("/rules/:id", (req, res) => { const existing = stmts.getAlertRule.get(req.params.id); if (!existing) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } }); } const { name, config, enabled, cooldown_seconds } = req.body || {}; if (name != null && (typeof name !== "string" || !name.trim())) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: "name must be a non-empty string" }, }); } let configJson = null; if (config != null) { // rule_type is immutable — validate the new config against the stored type const validated = validateRuleConfig(existing.rule_type, config); if (!validated.ok) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } }); } configJson = JSON.stringify(validated.config); } if (cooldown_seconds != null && (!Number.isInteger(cooldown_seconds) || cooldown_seconds < 0)) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" }, }); } stmts.updateAlertRule.run( name != null ? name.trim() : null, configJson, enabled == null ? null : enabled ? 1 : 0, cooldown_seconds ?? null, req.params.id ); invalidateRuleCache(); res.json({ rule: serializeRule(stmts.getAlertRule.get(req.params.id)) }); }); // DELETE /api/alerts/rules/:id - Delete an alert rule (its alert history // cascades away with it — the FK is ON DELETE CASCADE) router.delete("/rules/:id", (req, res) => { const existing = stmts.getAlertRule.get(req.params.id); if (!existing) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } }); } stmts.deleteAlertRule.run(req.params.id); invalidateRuleCache(); res.json({ ok: true }); }); // GET /api/alerts - Fired-alert feed, newest first. ?unacked=true filters to // unacknowledged alerts; limit/offset paginate. router.get("/", (req, res) => { // Clamp to sane bounds — negative values would make SQLite's LIMIT/OFFSET // misbehave (a negative LIMIT means "no limit"). const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 50, 200)); const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); const unackedOnly = req.query.unacked === "true"; if (memberId(req) != null) { // Server mode: a member's feed is their own sessions' alerts; one with no // session (a budget, a global threshold) is staff-only. const own = bySessionWhere(req); const count = (extra) => db .prepare(`SELECT COUNT(*) as count FROM alert_events WHERE ${own.where}${extra}`) .get(...own.params).count; const unackedSql = " AND acknowledged_at IS NULL"; const alerts = db .prepare( `SELECT * FROM alert_events WHERE ${own.where}${unackedOnly ? unackedSql : ""} ORDER BY triggered_at DESC, id DESC LIMIT ? OFFSET ?` ) .all(...own.params, limit, offset); const unacked = count(unackedSql); return res.json({ alerts, total: unackedOnly ? unacked : count(""), unacked, limit, offset, }); } const alerts = unackedOnly ? stmts.listUnackedAlertEvents.all(limit, offset) : stmts.listAlertEvents.all(limit, offset); const total = unackedOnly ? stmts.countUnackedAlertEvents.get().count : stmts.countAlertEvents.get().count; const unacked = stmts.countUnackedAlertEvents.get().count; res.json({ alerts, total, unacked, limit, offset }); }); // POST /api/alerts/:id/ack - Acknowledge one alert router.post("/:id(\\d+)/ack", (req, res) => { const alert = stmts.getAlertEvent.get(req.params.id); if (!alert) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert not found" } }); } stmts.ackAlertEvent.run(req.params.id); const updated = stmts.getAlertEvent.get(req.params.id); broadcast("alert_updated", updated); res.json({ alert: updated }); }); // POST /api/alerts/ack-all - Acknowledge every unacked alert router.post("/ack-all", (req, res) => { // A server-mode member acknowledges only their own sessions' alerts. const own = bySessionWhere(req); const info = memberId(req) == null ? stmts.ackAllAlertEvents.run() : db .prepare( `UPDATE alert_events SET acknowledged_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE acknowledged_at IS NULL AND ${own.where}` ) .run(...own.params); if (info.changes > 0) broadcast("alert_updated", { acked_all: true }); res.json({ ok: true, acknowledged: info.changes }); }); module.exports = router; module.exports.RULE_TYPES = RULE_TYPES;