193 lines
8.0 KiB
JavaScript
193 lines
8.0 KiB
JavaScript
// Microsoft Teams integration: posts alerts to a Teams channel/chat via an incoming webhook URL.
|
|
// Supports the modern Power Automate "Workflows" webhook (Adaptive Card envelope) and the legacy
|
|
// Office 365 connector ("MessageCard"). Best-effort delivery — failures are counted, never thrown,
|
|
// so a bad Teams endpoint can't break the alert cycle.
|
|
const { all, audit, now, run } = require('../db');
|
|
const { logEvent } = require('../logger');
|
|
|
|
const TIMEOUT_MS = 10000;
|
|
const FORMATS = ['adaptive', 'messagecard'];
|
|
const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
|
|
|
|
function readSettings() {
|
|
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'teams_%'");
|
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
}
|
|
|
|
// Raw config used for delivery.
|
|
function getConfig() {
|
|
const s = readSettings();
|
|
return {
|
|
enabled: s.teams_enabled === 'true',
|
|
webhookUrl: s.teams_webhook_url || '',
|
|
format: FORMATS.includes(s.teams_format) ? s.teams_format : 'adaptive',
|
|
minSeverity: SEVERITY_RANK[s.teams_min_severity] !== undefined ? s.teams_min_severity : 'warning'
|
|
};
|
|
}
|
|
|
|
// Safe config for the API/UI.
|
|
function publicConfig() {
|
|
const c = getConfig();
|
|
return {
|
|
teams_enabled: c.enabled,
|
|
teams_webhook_url: c.webhookUrl,
|
|
teams_format: c.format,
|
|
teams_min_severity: c.minSeverity,
|
|
teams_configured: Boolean(c.webhookUrl)
|
|
};
|
|
}
|
|
|
|
function setValue(key, value) {
|
|
run(
|
|
'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
|
|
[key, String(value), now()]
|
|
);
|
|
}
|
|
|
|
function saveConfig(body, userId) {
|
|
if (body.teams_enabled !== undefined) setValue('teams_enabled', body.teams_enabled ? 'true' : 'false');
|
|
if (body.teams_webhook_url !== undefined) setValue('teams_webhook_url', body.teams_webhook_url || '');
|
|
if (body.teams_format !== undefined) setValue('teams_format', FORMATS.includes(body.teams_format) ? body.teams_format : 'adaptive');
|
|
if (body.teams_min_severity !== undefined) setValue('teams_min_severity', SEVERITY_RANK[body.teams_min_severity] !== undefined ? body.teams_min_severity : 'warning');
|
|
audit(userId, 'update', 'teams_settings', null, null, body);
|
|
return publicConfig();
|
|
}
|
|
|
|
// ---- Card builders ---------------------------------------------------------
|
|
|
|
function adaptiveColor(severity) {
|
|
if (severity === 'critical') return 'attention';
|
|
if (severity === 'warning') return 'warning';
|
|
return 'accent';
|
|
}
|
|
|
|
function messageCardColor(severity) {
|
|
if (severity === 'critical') return 'b3261e';
|
|
if (severity === 'warning') return 'a56a00';
|
|
return '2457a6';
|
|
}
|
|
|
|
function highestSeverity(alerts) {
|
|
return alerts.reduce((top, a) => (SEVERITY_RANK[a.severity] > SEVERITY_RANK[top] ? a.severity : top), 'info');
|
|
}
|
|
|
|
function alertFacts(alert) {
|
|
const facts = [];
|
|
if (alert.due_date) facts.push({ title: 'Due', value: String(alert.due_date), name: 'Due' });
|
|
if (alert.asset_code) facts.push({ title: 'Asset', value: String(alert.asset_code), name: 'Asset' });
|
|
if (alert.type) facts.push({ title: 'Type', value: String(alert.type), name: 'Type' });
|
|
return facts;
|
|
}
|
|
|
|
// Adaptive Card (Power Automate Workflows webhook). One card summarizing all alerts.
|
|
function buildAdaptiveCard(alerts, heading, intro) {
|
|
const body = [
|
|
{ type: 'TextBlock', size: 'Large', weight: 'Bolder', text: heading },
|
|
{ type: 'TextBlock', text: intro, isSubtle: true, wrap: true, spacing: 'None' }
|
|
];
|
|
for (const alert of alerts) {
|
|
body.push({
|
|
type: 'TextBlock', weight: 'Bolder', color: adaptiveColor(alert.severity),
|
|
text: `${String(alert.severity || 'info').toUpperCase()} · ${alert.title}`, wrap: true, separator: true
|
|
});
|
|
if (alert.message) body.push({ type: 'TextBlock', text: alert.message, wrap: true, spacing: 'None' });
|
|
const facts = alertFacts(alert);
|
|
if (facts.length) body.push({ type: 'FactSet', facts: facts.map((f) => ({ title: f.title, value: f.value })) });
|
|
}
|
|
return {
|
|
type: 'message',
|
|
attachments: [{
|
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
content: {
|
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
type: 'AdaptiveCard',
|
|
version: '1.4',
|
|
msteams: { width: 'Full' },
|
|
body
|
|
}
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Legacy MessageCard (Office 365 connector).
|
|
function buildMessageCard(alerts, heading, intro) {
|
|
return {
|
|
'@type': 'MessageCard',
|
|
'@context': 'http://schema.org/extensions',
|
|
themeColor: messageCardColor(highestSeverity(alerts)),
|
|
summary: heading,
|
|
title: `${heading} — ${intro}`,
|
|
sections: alerts.map((alert) => ({
|
|
activityTitle: `${String(alert.severity || 'info').toUpperCase()} · ${alert.title}`,
|
|
text: alert.message || '',
|
|
facts: alertFacts(alert).map((f) => ({ name: f.name, value: f.value }))
|
|
}))
|
|
};
|
|
}
|
|
|
|
function buildPayload(alerts, config, { heading = 'DepreCore alerts', intro } = {}) {
|
|
const text = intro || `${alerts.length} item(s) need attention.`;
|
|
return config.format === 'messagecard'
|
|
? buildMessageCard(alerts, heading, text)
|
|
: buildAdaptiveCard(alerts, heading, text);
|
|
}
|
|
|
|
// ---- Delivery --------------------------------------------------------------
|
|
|
|
async function postPayload(url, payload) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal
|
|
});
|
|
return { ok: response.ok, status: response.status };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
// Post the pending alerts as a single digest card. Honors the configured minimum severity so the
|
|
// channel isn't flooded with low-priority items.
|
|
async function deliverAlerts(alerts) {
|
|
const c = getConfig();
|
|
if (!c.enabled || !c.webhookUrl || !alerts.length) {
|
|
return { attempted: false, delivered: 0, failed: 0 };
|
|
}
|
|
const threshold = SEVERITY_RANK[c.minSeverity] ?? 1;
|
|
const relevant = alerts.filter((a) => (SEVERITY_RANK[a.severity] ?? 0) >= threshold);
|
|
if (!relevant.length) return { attempted: false, delivered: 0, failed: 0 };
|
|
try {
|
|
const result = await postPayload(c.webhookUrl, buildPayload(relevant, c));
|
|
logEvent('teams', `Posted ${relevant.length} alert(s) to Teams (HTTP ${result.status})`, { count: relevant.length, status: result.status, format: c.format }, result.ok ? 'info' : 'warn');
|
|
return { attempted: true, delivered: result.ok ? 1 : 0, failed: result.ok ? 0 : 1, status: result.status, count: relevant.length };
|
|
} catch (error) {
|
|
logEvent('teams', `Teams alert post failed: ${error.message}`, { count: relevant.length }, 'error');
|
|
return { attempted: true, delivered: 0, failed: 1 };
|
|
}
|
|
}
|
|
|
|
// Post a test card to verify the webhook URL.
|
|
async function sendTest(userId) {
|
|
const c = getConfig();
|
|
if (!c.webhookUrl) throw Object.assign(new Error('A Teams webhook URL is not configured'), { status: 400 });
|
|
const sample = [{ severity: 'info', title: 'DepreCore Teams test', message: 'If you can see this card in your channel, the Teams integration is configured correctly.', type: 'test' }];
|
|
const payload = buildPayload(sample, c, { heading: 'DepreCore Teams test', intro: 'Connection test' });
|
|
let result;
|
|
try {
|
|
result = await postPayload(c.webhookUrl, payload);
|
|
} catch (error) {
|
|
logEvent('teams', `Test card request failed: ${error.message}`, {}, 'error');
|
|
throw Object.assign(new Error(`Teams request failed: ${error.message}`), { status: 400 });
|
|
}
|
|
logEvent('teams', `Test card sent (HTTP ${result.status})`, { status: result.status, format: c.format }, result.ok ? 'info' : 'warn');
|
|
audit(userId, 'send', 'teams_test', null, null, { status: result.status });
|
|
if (!result.ok) throw Object.assign(new Error(`Teams endpoint returned HTTP ${result.status}`), { status: 400 });
|
|
return { ok: true, status: result.status };
|
|
}
|
|
|
|
module.exports = { buildPayload, deliverAlerts, getConfig, publicConfig, saveConfig, sendTest };
|