// 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 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)); return { attempted: true, delivered: result.ok ? 1 : 0, failed: result.ok ? 0 : 1, status: result.status, count: relevant.length }; } catch { 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) { throw Object.assign(new Error(`Teams request failed: ${error.message}`), { status: 400 }); } 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 };