const { all, audit, now, one, run } = require('../db'); const { getConfig, sendMail } = require('./notifications'); const { deliverAlerts } = require('./webhooks'); const servicenow = require('./servicenow'); const teams = require('./teams'); const { pmAlertCandidates } = require('./pm'); function todayStr() { return new Date().toISOString().slice(0, 10); } function daysUntil(dateStr) { return Math.round((new Date(`${dateStr}T00:00:00`) - new Date(`${todayStr()}T00:00:00`)) / 86400000); } function mk(type, referenceType, referenceId, assetId, severity, title, message, dueDate) { return { type, reference_type: referenceType, reference_id: referenceId, asset_id: assetId, severity, title, message, due_date: dueDate }; } // Compute the current set of due/overdue/expiring items that warrant an alert. function candidates(leadDays) { const today = todayStr(); const horizon = new Date(Date.now() + leadDays * 86400000).toISOString().slice(0, 10); const list = []; const tasks = all( `SELECT t.*, a.asset_id AS asset_code FROM tasks t LEFT JOIN assets a ON a.id = t.asset_id WHERE t.status != 'complete' AND t.due_date IS NOT NULL` ); for (const task of tasks) { const where = task.asset_code ? ` on ${task.asset_code}` : ''; if (task.due_date < today) { list.push(mk('maintenance_overdue', 'task', task.id, task.asset_id, 'critical', `Overdue: ${task.title}`, `"${task.title}"${where} was due ${task.due_date}.`, task.due_date)); } else if (task.due_date <= horizon) { list.push(mk('maintenance_due', 'task', task.id, task.asset_id, 'warning', `Upcoming: ${task.title}`, `"${task.title}"${where} is due ${task.due_date}.`, task.due_date)); } } const warranties = all( `SELECT w.*, a.asset_id AS asset_code FROM warranties w LEFT JOIN assets a ON a.id = w.asset_id WHERE w.expiration_date IS NOT NULL` ); for (const w of warranties) { const label = w.provider || 'Warranty'; const where = w.asset_code ? ` on ${w.asset_code}` : ''; if (w.expiration_date < today) { list.push(mk('warranty_expired', 'warranty', w.id, w.asset_id, 'critical', 'Warranty expired', `${label}${where} expired ${w.expiration_date}.`, w.expiration_date)); } else if (w.expiration_date <= horizon) { const sev = daysUntil(w.expiration_date) <= 7 ? 'critical' : 'warning'; list.push(mk('warranty_expiring', 'warranty', w.id, w.asset_id, sev, 'Warranty expiring', `${label}${where} expires ${w.expiration_date}.`, w.expiration_date)); } } const leases = all( `SELECT l.*, a.asset_id AS asset_code FROM leases l LEFT JOIN assets a ON a.id = l.asset_id WHERE l.end_date IS NOT NULL` ); for (const l of leases) { if (l.end_date < today || l.end_date > horizon) continue; const where = l.asset_code ? ` on ${l.asset_code}` : ''; const sev = daysUntil(l.end_date) <= 7 ? 'critical' : 'warning'; list.push(mk('lease_expiring', 'lease', l.id, l.asset_id, sev, 'Lease ending', `Lease with ${l.lessor || 'lessor'}${where} ends ${l.end_date}.`, l.end_date)); } list.push(...pmAlertCandidates()); return list; } // Reconcile computed candidates against stored alerts: create new, refresh existing, // resolve ones that no longer apply, and respect dismissals. function scanAlerts(userId) { const cfg = getConfig(); const ts = now(); const cands = candidates(cfg.leadDays); const seen = new Set(); const created = []; for (const c of cands) { seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`); // Every candidate derives from an asset; the alert inherits that asset's company for filtering. const entityId = c.asset_id ? (one('SELECT entity_id FROM assets WHERE id = ?', [c.asset_id])?.entity_id ?? null) : null; const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]); if (existing) { if (existing.status === 'dismissed') continue; run( `UPDATE alerts SET asset_id = ?, entity_id = ?, severity = ?, title = ?, message = ?, due_date = ?, status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ? WHERE id = ?`, [c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, existing.id] ); } else { const result = run( `INSERT INTO alerts (type, reference_type, reference_id, asset_id, entity_id, severity, title, message, due_date, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, [c.type, c.reference_type, c.reference_id, c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, ts] ); created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid])); } } for (const alert of all("SELECT * FROM alerts WHERE status IN ('open', 'acknowledged')")) { if (!seen.has(`${alert.reference_type}:${alert.reference_id}:${alert.type}`)) { run("UPDATE alerts SET status = 'resolved', updated_at = ? WHERE id = ?", [ts, alert.id]); } } if (userId) audit(userId, 'scan', 'alerts', null, null, { created: created.length }); return { created, openCount: one("SELECT COUNT(*) AS c FROM alerts WHERE status = 'open'").c }; } function listAlerts(query = {}) { return all( `SELECT al.*, a.asset_id AS asset_code, a.description AS asset_description, u.name AS acknowledged_by_name FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id LEFT JOIN users u ON u.id = al.acknowledged_by WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?) AND (? IS NULL OR al.entity_id = ?) ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`, [query.status || null, query.status || null, query.type || null, query.type || null, query.entity_id || null, query.entity_id || null] ); } function summary(companyId) { const rows = all( "SELECT status, severity, COUNT(*) AS count FROM alerts WHERE (? IS NULL OR entity_id = ?) GROUP BY status, severity", [companyId || null, companyId || null] ); const open = rows.filter((r) => r.status === 'open'); return { open: open.reduce((s, r) => s + r.count, 0), critical: open.filter((r) => r.severity === 'critical').reduce((s, r) => s + r.count, 0), acknowledged: rows.filter((r) => r.status === 'acknowledged').reduce((s, r) => s + r.count, 0) }; } function setStatus(id, status, userId) { const before = one('SELECT * FROM alerts WHERE id = ?', [id]); if (!before) return null; run( 'UPDATE alerts SET status = ?, acknowledged_by = ?, acknowledged_at = ?, updated_at = ? WHERE id = ?', [ status, status === 'acknowledged' ? userId : before.acknowledged_by, status === 'acknowledged' ? now() : before.acknowledged_at, now(), id ] ); audit(userId, status, 'alert', id, before, { status }); return one('SELECT * FROM alerts WHERE id = ?', [id]); } function digestHtml(alerts) { const rows = alerts.map((a) => `
${alerts.length} item(s) need attention.
` + `| Severity | Alert | ` + `Detail | Due |
|---|