Updated A LOT
This commit is contained in:
213
server/services/alerts.js
Normal file
213
server/services/alerts.js
Normal file
@@ -0,0 +1,213 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user