Files
Nexus/packages/api/src/lib/webhook-dispatcher.ts
T

148 lines
3.8 KiB
TypeScript

/**
* Outbound webhook dispatcher.
*
* Fetches active webhooks matching a given event, sends POST requests
* with JSON payloads, and optionally signs them with HMAC-SHA256.
*
* Fire-and-forget — errors are logged, never thrown.
*/
import { createHmac } from "node:crypto";
import { logger } from "./logger.js";
import { sendSlackNotification } from "./slack-notify.js";
/** Available webhook event types. */
export const WEBHOOK_EVENTS = [
"allocation.created",
"allocation.updated",
"allocation.deleted",
"project.created",
"project.status_changed",
"vacation.approved",
"estimate.submitted",
"estimate.approved",
] as const;
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
interface MinimalDb {
webhook: {
findMany: (args: {
where: { isActive: boolean; events: { has: string } };
}) => Promise<
Array<{
id: string;
name: string;
url: string;
secret: string | null;
events: string[];
}>
>;
};
}
/**
* Dispatch an event to all matching active webhooks.
* This is fire-and-forget: errors are logged and swallowed.
*/
export function dispatchWebhooks(
db: MinimalDb,
event: string,
payload: Record<string, unknown>,
): void {
void _dispatch(db, event, payload);
}
async function _dispatch(
db: MinimalDb,
event: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
const webhooks = await db.webhook.findMany({
where: { isActive: true, events: { has: event } },
});
if (webhooks.length === 0) return;
const timestamp = new Date().toISOString();
const body = JSON.stringify({ event, timestamp, payload });
const promises = webhooks.map((wh) =>
_sendToWebhook(wh, event, body, timestamp, payload),
);
await Promise.allSettled(promises);
} catch (err) {
logger.error({ err, event }, "Failed to dispatch webhooks");
}
}
async function _sendToWebhook(
wh: { id: string; name: string; url: string; secret: string | null },
event: string,
body: string,
timestamp: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
// Slack-specific path: use the Slack notification helper
if (wh.url.includes("hooks.slack.com")) {
const message = formatSlackMessage(event, payload);
await sendSlackNotification(wh.url, message);
return;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Event": event,
"X-Webhook-Timestamp": timestamp,
};
if (wh.secret) {
const signature = createHmac("sha256", wh.secret)
.update(body)
.digest("hex");
headers["X-Webhook-Signature"] = signature;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
const response = await fetch(wh.url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Webhook responded with HTTP ${response.status}`);
}
} finally {
clearTimeout(timeout);
}
} catch (err) {
logger.warn(
{ err, event, webhookId: wh.id, webhookName: wh.name, webhookUrl: wh.url },
"Webhook delivery failed",
);
}
}
/**
* Format a human-readable Slack message from a webhook event.
*/
function formatSlackMessage(
event: string,
payload: Record<string, unknown>,
): string {
const label = event.replace(/\./g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const id = (payload["id"] as string) ?? (payload["projectId"] as string) ?? "";
const name = (payload["name"] as string) ?? "";
const parts = [`*${label}*`];
if (name) parts.push(`\u2022 ${name}`);
if (id) parts.push(`ID: \`${id}\``);
return parts.join("\n");
}