import { Router } from 'express'; import { query } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { alertService } from '../services/AlertService'; const router = Router(); // All alert routes require authentication router.use(requireAuth); // ── Default rules seed ───────────────────────────────────────────────────── const DEFAULT_RULES = [ { event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 5 }, { event_type: 'device_online', enabled: true, threshold: null, cooldown_min: 5 }, { event_type: 'log_error', enabled: false, threshold: null, cooldown_min: 15 }, { event_type: 'log_warning', enabled: false, threshold: null, cooldown_min: 30 }, { event_type: 'high_cpu', enabled: false, threshold: 90, cooldown_min: 30 }, { event_type: 'high_memory', enabled: false, threshold: 90, cooldown_min: 30 }, { event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 }, { event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 }, { event_type: 'firmware_update_available', enabled: true, threshold: null, cooldown_min: 1440 }, ]; async function ensureDefaultRules(): Promise { for (const rule of DEFAULT_RULES) { await query( `INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min) VALUES ($1, $2, $3, $4) ON CONFLICT (event_type) DO NOTHING`, [rule.event_type, rule.enabled, rule.threshold, rule.cooldown_min] ); } } // ── Alert Rules ──────────────────────────────────────────────────────────── /** GET /api/alerts/rules — list all rules (seeds defaults on first call) */ router.get('/rules', async (_req, res) => { await ensureDefaultRules(); const rules = await query( `SELECT event_type, enabled, threshold, cooldown_min, updated_at FROM alert_rules ORDER BY event_type` ); res.json(rules); }); /** PUT /api/alerts/rules/:type — update a single rule */ router.put('/rules/:type', requireWrite, async (req, res) => { const { type } = req.params; const { enabled, threshold, cooldown_min } = req.body; const row = await alertService.upsertRule(type, { enabled: enabled !== undefined ? Boolean(enabled) : undefined, threshold: threshold !== undefined ? (Number(threshold) || null) : undefined, cooldown_min: cooldown_min !== undefined ? Number(cooldown_min) : undefined, }); res.json(row); }); // ── Alert Channels ───────────────────────────────────────────────────────── /** GET /api/alerts/channels */ router.get('/channels', async (_req, res) => { const channels = await query( `SELECT id, name, type, enabled, config, created_at, updated_at FROM alert_channels ORDER BY id` ); // Mask sensitive fields before returning const masked = channels.map((ch: Record) => ({ ...ch, config: maskConfig(ch.type as string, ch.config as Record), })); res.json(masked); }); /** POST /api/alerts/channels */ router.post('/channels', requireWrite, async (req, res) => { const { name, type, enabled = true, config = {} } = req.body; if (!name || !type) { return res.status(400).json({ error: 'name and type are required' }); } const validTypes = ['email', 'slack', 'discord', 'telegram']; if (!validTypes.includes(type)) { return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` }); } const rows = await query( `INSERT INTO alert_channels (name, type, enabled, config) VALUES ($1, $2, $3, $4) RETURNING *`, [name, type, Boolean(enabled), JSON.stringify(config)] ); const ch = rows[0] as Record; res.status(201).json({ ...ch, config: maskConfig(type, config), }); }); /** PUT /api/alerts/channels/:id */ router.put('/channels/:id', requireWrite, async (req, res) => { const id = parseInt(req.params.id, 10); const { name, enabled, config } = req.body; // Fetch existing so we can merge config (preserve masked/unchanged fields) const existing = await query<{ config: Record; type: string }>( `SELECT config, type FROM alert_channels WHERE id = $1`, [id] ); if (!existing[0]) return res.status(404).json({ error: 'Channel not found' }); const existingConfig = existing[0].config as Record; const type = existing[0].type; // Merge new config over existing, preserving existing passwords when placeholders sent const mergedConfig = mergeConfig(type, existingConfig, config ?? {}); const rows = await query( `UPDATE alert_channels SET name = COALESCE($2, name), enabled = COALESCE($3, enabled), config = $4, updated_at = NOW() WHERE id = $1 RETURNING *`, [id, name ?? null, enabled !== undefined ? Boolean(enabled) : null, JSON.stringify(mergedConfig)] ); if (!rows[0]) return res.status(404).json({ error: 'Channel not found' }); const ch = rows[0] as Record; res.json({ ...ch, config: maskConfig(type, mergedConfig) }); }); /** DELETE /api/alerts/channels/:id */ router.delete('/channels/:id', requireWrite, async (req, res) => { const id = parseInt(req.params.id, 10); await query(`DELETE FROM alert_channels WHERE id = $1`, [id]); res.status(204).end(); }); /** POST /api/alerts/channels/:id/test */ router.post('/channels/:id/test', requireWrite, async (req, res) => { const id = parseInt(req.params.id, 10); try { await alertService.testChannel(id); res.json({ message: 'Test alert sent successfully' }); } catch (err) { res.status(400).json({ error: (err as Error).message }); } }); // ── Alert History ────────────────────────────────────────────────────────── /** GET /api/alerts/history?limit=50 */ router.get('/history', async (req, res) => { const limit = Math.min(parseInt(String(req.query.limit ?? 50), 10), 200); const rows = await query( `SELECT h.id, h.event_type, h.device_id, h.device_name, h.message, h.channels_notified, h.sent_at FROM alert_history h ORDER BY h.sent_at DESC LIMIT $1`, [limit] ); res.json(rows); }); // ── Helpers ──────────────────────────────────────────────────────────────── const SENSITIVE_KEYS: Record = { email: ['smtp_pass'], slack: [], discord: [], telegram: ['bot_token'], }; function maskConfig(type: string, config: Record): Record { const masked = { ...config }; for (const key of SENSITIVE_KEYS[type] ?? []) { if (masked[key]) masked[key] = '••••••••'; } return masked; } /** When updating, if a sensitive field comes back as the mask placeholder, keep the original. */ function mergeConfig( type: string, existing: Record, incoming: Record ): Record { const merged = { ...existing, ...incoming }; for (const key of SENSITIVE_KEYS[type] ?? []) { if (incoming[key] === '••••••••') { merged[key] = existing[key]; // restore original } } return merged; } export default router;