From c164395915e2448d9633c1a18d1140b8df2898ab Mon Sep 17 00:00:00 2001 From: mpuckett Date: Sun, 7 Jun 2026 23:32:31 -0500 Subject: [PATCH] MS Teams integrations --- README.md | 2 +- server/app.js | 2 + server/routes/teams.js | 23 ++++ server/services/alerts.js | 12 +- server/services/teams.js | 187 +++++++++++++++++++++++++++++++ server/smoke-test.js | 55 +++++++++ src/components/TeamsSettings.vue | 133 ++++++++++++++++++++++ src/views/AdminView.vue | 5 +- 8 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 server/routes/teams.js create mode 100644 server/services/teams.js create mode 100644 src/components/TeamsSettings.vue diff --git a/README.md b/README.md index 4da9efc..de00211 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DepreCore -DepreCore is a fixed asset management and depreciation SPA for small and mid-sized organizations. This build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, and admin configuration. +DepreCore is a fixed asset management and depreciation platform for small and mid-sized organizations. This build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, and admin configuration. Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline. diff --git a/server/app.js b/server/app.js index 52c068e..2496c31 100644 --- a/server/app.js +++ b/server/app.js @@ -25,6 +25,7 @@ const profileRoutes = require('./routes/profile'); const referenceRoutes = require('./routes/reference'); const reportRoutes = require('./routes/reports'); const serviceNowRoutes = require('./routes/servicenow'); +const teamsRoutes = require('./routes/teams'); const taxRuleRoutes = require('./routes/taxRules'); const templateRoutes = require('./routes/templates'); const workdayRoutes = require('./routes/workday'); @@ -61,6 +62,7 @@ function createApp() { app.use('/api', taxRuleRoutes); app.use('/api', reportRoutes); app.use('/api', serviceNowRoutes); + app.use('/api', teamsRoutes); app.use('/api', dataPortabilityRoutes); app.use('/api', adminRoutes); app.use('/api', workdayRoutes); diff --git a/server/routes/teams.js b/server/routes/teams.js new file mode 100644 index 0000000..3ed724b --- /dev/null +++ b/server/routes/teams.js @@ -0,0 +1,23 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const { publicConfig, saveConfig, sendTest } = require('../services/teams'); + +const router = express.Router(); + +router.get('/teams/settings', requireCapability('admin.integrations'), (req, res) => { + res.json({ settings: publicConfig() }); +}); + +router.put('/teams/settings', requireCapability('admin.integrations'), (req, res) => { + res.json({ settings: saveConfig(req.body, req.user.id) }); +}); + +router.post('/teams/test', requireCapability('admin.integrations'), async (req, res, next) => { + try { + res.json(await sendTest(req.user.id)); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/server/services/alerts.js b/server/services/alerts.js index 2c36eeb..89b9c57 100644 --- a/server/services/alerts.js +++ b/server/services/alerts.js @@ -2,6 +2,7 @@ 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() { @@ -171,10 +172,12 @@ async function runAlertCycle(userId) { 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; - if (!emailReady && !webhookReady && !ticketReady) return result; + const teamsReady = teamsCfg.enabled && teamsCfg.webhookUrl; + if (!emailReady && !webhookReady && !ticketReady && !teamsReady) return result; const pending = all( `SELECT al.*, a.asset_id AS asset_code @@ -205,6 +208,13 @@ async function runAlertCycle(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; diff --git a/server/services/teams.js b/server/services/teams.js new file mode 100644 index 0000000..860f62e --- /dev/null +++ b/server/services/teams.js @@ -0,0 +1,187 @@ +// 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 }; diff --git a/server/smoke-test.js b/server/smoke-test.js index 9f13da2..9a05787 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1387,6 +1387,61 @@ async function main() { await new Promise((resolve) => hookServer.close(resolve)); } + // Microsoft Teams integration: posts alert digests to a Teams webhook (Adaptive Card / MessageCard). + const teamsHits = []; + const teamsServer = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + teamsHits.push({ raw, body: JSON.parse(raw || '{}') }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('1'); + }); + }); + await new Promise((resolve) => teamsServer.listen(4300, '127.0.0.1', resolve)); + try { + const teamsUrl = 'http://127.0.0.1:4300/teams'; + const savedTeams = await request('/api/teams/settings', { + method: 'PUT', headers: auth, + body: JSON.stringify({ teams_enabled: true, teams_webhook_url: teamsUrl, teams_format: 'adaptive', teams_min_severity: 'info' }) + }); + if (savedTeams.settings.teams_webhook_url !== teamsUrl || savedTeams.settings.teams_format !== 'adaptive' || !savedTeams.settings.teams_enabled) { + throw new Error('Teams settings did not persist'); + } + + // Test card uses the Adaptive Card (Workflows) envelope. + const teamsTest = await request('/api/teams/test', { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (!teamsTest.ok) throw new Error('Teams test card was not delivered'); + const adaptiveHit = teamsHits.find((h) => h.body.type === 'message'); + if (!adaptiveHit || !Array.isArray(adaptiveHit.body.attachments) || + adaptiveHit.body.attachments[0].contentType !== 'application/vnd.microsoft.card.adaptive' || + adaptiveHit.body.attachments[0].content.type !== 'AdaptiveCard') { + throw new Error('Teams adaptive card envelope was malformed'); + } + + // Disable the (now-closed) webhook so the next cycle is handled by Teams, then raise a fresh alert. + await request('/api/notifications/settings', { method: 'PUT', headers: auth, body: JSON.stringify({ webhook_enabled: false }) }); + await request(`/api/assets/${assetId}/tasks`, { + method: 'POST', headers: auth, + body: JSON.stringify({ title: `Teams overdue ${suffix}`, type: 'inspection', due_date: '2019-02-02' }) + }); + const teamsCycle = await request('/api/alerts/run', { method: 'POST', headers: auth }); + if (!teamsCycle.teamsPosted || !(teamsCycle.teamsDelivered > 0)) throw new Error('Alert cycle did not post alerts to Teams'); + const digestHit = teamsHits.find((h) => h.body.type === 'message' && JSON.stringify(h.body).includes(`Teams overdue ${suffix}`)); + if (!digestHit) throw new Error('Teams did not receive the alert digest card'); + + // Legacy MessageCard format produces a different envelope. + await request('/api/teams/settings', { method: 'PUT', headers: auth, body: JSON.stringify({ teams_format: 'messagecard' }) }); + const mcTest = await request('/api/teams/test', { method: 'POST', headers: auth, body: JSON.stringify({}) }); + if (!mcTest.ok) throw new Error('Teams MessageCard test was not delivered'); + if (!teamsHits.some((h) => h.body['@type'] === 'MessageCard')) throw new Error('Teams MessageCard payload was not produced'); + + // Disable Teams so it doesn't interfere with later alert assertions. + await request('/api/teams/settings', { method: 'PUT', headers: auth, body: JSON.stringify({ teams_enabled: false }) }); + } finally { + await new Promise((resolve) => teamsServer.close(resolve)); + } + // ServiceNow integration: incident push (Table API) + CMDB asset pull, against a mock instance. const snHits = []; const snServer = http.createServer((req, res) => { diff --git a/src/components/TeamsSettings.vue b/src/components/TeamsSettings.vue new file mode 100644 index 0000000..cb5133a --- /dev/null +++ b/src/components/TeamsSettings.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/views/AdminView.vue b/src/views/AdminView.vue index a85ccfd..7d6bc87 100644 --- a/src/views/AdminView.vue +++ b/src/views/AdminView.vue @@ -215,6 +215,8 @@ + + @@ -304,10 +306,11 @@ import PartCategorySettings from '../components/PartCategorySettings.vue'; import PmCategorySettings from '../components/PmCategorySettings.vue'; import PmSettings from '../components/PmSettings.vue'; import ServiceNowSettings from '../components/ServiceNowSettings.vue'; +import TeamsSettings from '../components/TeamsSettings.vue'; import WebhookSettings from '../components/WebhookSettings.vue'; export default { - components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, WebhookSettings }, + components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings }, props: { token: { type: String, default: '' }, section: { type: String, default: 'admin-users' },