214 lines
9.2 KiB
JavaScript
214 lines
9.2 KiB
JavaScript
const { all, audit, now, one, run } = require('../db');
|
|
const { getConfig, sendMail } = require('./notifications');
|
|
const { deliverAlerts } = require('./webhooks');
|
|
const servicenow = require('./servicenow');
|
|
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}`);
|
|
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 = ?, severity = ?, title = ?, message = ?, due_date = ?,
|
|
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
|
|
WHERE id = ?`,
|
|
[c.asset_id, 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, severity, title, message, due_date, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
|
[c.type, c.reference_type, c.reference_id, c.asset_id, 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 = ?)
|
|
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]
|
|
);
|
|
}
|
|
|
|
function summary() {
|
|
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
|
|
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>MixedAssets 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 `MixedAssets 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 emailReady = cfg.enabled && cfg.host && cfg.recipients.length;
|
|
const webhookReady = cfg.webhookEnabled && cfg.webhookUrl;
|
|
const ticketReady = snCfg.ticketEnabled && snCfg.autoTicket && snCfg.instanceUrl;
|
|
if (!emailReady && !webhookReady && !ticketReady) 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: `MixedAssets: ${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;
|
|
}
|
|
|
|
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 };
|