Files
MixedAssets/server/services/webhooks.js
2026-06-05 04:15:24 -05:00

89 lines
3.1 KiB
JavaScript

const crypto = require('crypto');
const { audit } = require('../db');
const { getConfig } = require('./notifications');
const TIMEOUT_MS = 10000;
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
function buildPayload(alert) {
return {
event: 'alert',
id: alert.id,
type: alert.type,
severity: alert.severity,
title: alert.title,
message: alert.message || null,
due_date: alert.due_date || null,
status: alert.status,
reference_type: alert.reference_type,
reference_id: alert.reference_id,
asset_id: alert.asset_id || null,
asset_code: alert.asset_code || null,
created_at: alert.created_at,
sent_at: new Date().toISOString()
};
}
// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256
// so the receiver can verify authenticity via the X-MixedAssets-Signature header.
async function postJson(url, secret, payload) {
const body = JSON.stringify(payload);
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' };
if (secret) {
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
headers['X-MixedAssets-Signature'] = `sha256=${signature}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
return { ok: response.ok, status: response.status };
} finally {
clearTimeout(timer);
}
}
// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted
// but never throws, so one bad endpoint can't break the alert cycle.
async function deliverAlerts(alerts) {
const c = getConfig();
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
return { attempted: false, delivered: 0, failed: 0 };
}
let delivered = 0;
let failed = 0;
for (const alert of alerts) {
try {
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
if (result.ok) delivered += 1;
else failed += 1;
} catch {
failed += 1;
}
}
return { attempted: true, delivered, failed };
}
async function sendTestWebhook(userId) {
const c = getConfig();
if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
const payload = {
event: 'test',
severity: 'info',
title: 'MixedAssets webhook test',
message: 'If you received this, your webhook destination is configured correctly.',
sent_at: new Date().toISOString()
};
let result;
try {
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
} catch (error) {
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
}
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
if (!result.ok) throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
return { ok: true, status: result.status };
}
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };