Files
MixedAssets/server/services/alerts.js
2026-06-08 00:54:21 -05:00

229 lines
10 KiB
JavaScript

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) =>
`<tr><td style="padding:6px 10px;color:${a.severity === 'critical' ? '#b3261e' : '#a56a00'};font-weight:700">${a.severity.toUpperCase()}</td>` +
`<td style="padding:6px 10px">${a.title}</td><td style="padding:6px 10px">${a.message || ''}</td>` +
`<td style="padding:6px 10px">${a.due_date || ''}</td></tr>`
).join('');
return `<h2>DepreCore alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
`<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px">` +
`<thead><tr><th align="left" style="padding:6px 10px">Severity</th><th align="left" style="padding:6px 10px">Alert</th>` +
`<th align="left" style="padding:6px 10px">Detail</th><th align="left" style="padding:6px 10px">Due</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function digestText(alerts) {
return `DepreCore alerts (${alerts.length}):\n` +
alerts.map((a) => `- [${a.severity}] ${a.title}${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
}
// Scan, then notify on newly raised, not-yet-notified open alerts: an email digest
// (when SMTP is enabled) and/or a per-alert JSON webhook POST (when enabled). An alert
// is marked notified once any channel has handled it, so it is delivered at most once.
async function runAlertCycle(userId) {
const { created } = scanAlerts(userId);
const cfg = getConfig();
const result = { created: created.length, emailed: false, webhooked: false };
const snCfg = servicenow.getConfig();
const teamsCfg = teams.getConfig();
const emailReady = cfg.enabled && cfg.host && cfg.recipients.length;
const webhookReady = cfg.webhookEnabled && cfg.webhookUrl;
const ticketReady = snCfg.ticketEnabled && snCfg.autoTicket && snCfg.instanceUrl;
const teamsReady = teamsCfg.enabled && teamsCfg.webhookUrl;
if (!emailReady && !webhookReady && !ticketReady && !teamsReady) return result;
const pending = all(
`SELECT al.*, a.asset_id AS asset_code
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
WHERE al.status = 'open' AND al.notified_at IS NULL`
);
if (!pending.length) return result;
if (emailReady) {
await sendMail({
subject: `DepreCore: ${pending.length} new alert(s)`,
html: digestHtml(pending),
text: digestText(pending)
});
result.emailed = true;
result.count = pending.length;
}
if (webhookReady) {
const delivery = await deliverAlerts(pending);
result.webhooked = delivery.attempted;
result.delivered = delivery.delivered;
result.failed = delivery.failed;
}
if (ticketReady) {
const tickets = await servicenow.autoTicketAlerts(pending, userId);
result.ticketed = tickets.created;
}
if (teamsReady) {
const teamsDelivery = await teams.deliverAlerts(pending);
result.teamsPosted = teamsDelivery.attempted;
result.teamsDelivered = teamsDelivery.delivered;
result.teamsFailed = teamsDelivery.failed;
}
const ts = now();
for (const alert of pending) run('UPDATE alerts SET notified_at = ? WHERE id = ?', [ts, alert.id]);
return result;
}
module.exports = { listAlerts, runAlertCycle, scanAlerts, setStatus, summary };