import { getRequestAuditContext } from "@/lib/audit"; export type TransactionalNotificationInput = { to: string; subject: string; text: string; html?: string; metadata?: Record; }; export type TransactionalNotificationResult = { ok: boolean; provider?: string; error?: string; }; type TransportName = "webhook" | "console" | "none"; function normalizeProvider(value: string | undefined) { const normalized = value?.trim().toLowerCase(); if (!normalized) { return "auto"; } if (normalized === "webhook") { return "webhook" as TransportName; } if (normalized === "console") { return "console" as TransportName; } return normalized as "auto" | TransportName; } function isProduction() { return process.env.NODE_ENV === "production"; } function buildPayload(input: TransactionalNotificationInput, context?: { ipAddress?: string | null; userAgent?: string | null }) { return { to: input.to, subject: input.subject, text: input.text, html: input.html ?? null, metadata: input.metadata ?? null, type: "transactional", providerContext: { ipAddress: context?.ipAddress ?? null, userAgent: context?.userAgent ?? null } }; } function getWebhookUrl() { return process.env.NOTIFICATION_WEBHOOK_URL?.trim(); } function getWebhookToken() { return process.env.NOTIFICATION_WEBHOOK_TOKEN?.trim(); } function getWebhookTimeoutMs() { const raw = process.env.NOTIFICATION_WEBHOOK_TIMEOUT_MS?.trim(); const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { return 10_000; } return Math.min(60_000, parsed); } async function sendViaWebhook(input: TransactionalNotificationInput, context?: { ipAddress?: string | null; userAgent?: string | null }) { const url = getWebhookUrl(); if (!url) { return { ok: false, error: "NOTIFICATION_WEBHOOK_URL is not configured" } as const; } const headers: Record = { "Content-Type": "application/json" }; const token = getWebhookToken(); if (token) { headers.Authorization = `Bearer ${token}`; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), getWebhookTimeoutMs()); try { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(buildPayload(input, context)), signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { const errorBody = await response.text(); return { ok: false, error: `Webhook notification failed ${response.status}: ${errorBody}`.slice(0, 600) }; } return { ok: true, provider: "webhook" }; } catch (error) { clearTimeout(timeout); return { ok: false, error: error instanceof Error ? error.message : "Webhook notification error" }; } } export async function sendTransactionalNotification(input: TransactionalNotificationInput): Promise { if (!input.to || !input.subject || !input.text) { return { ok: false, error: "Notification payload is incomplete" }; } const provider = normalizeProvider(process.env.NOTIFICATION_PROVIDER); const explicitWebhookUrl = getWebhookUrl(); const requestContext = await getRequestAuditContext().catch(() => ({ ipAddress: null, userAgent: null })); if (provider === "none") { return { ok: false, error: "Notification delivery disabled" }; } if (provider === "console" || (!provider && !isProduction())) { // eslint-disable-next-line no-console console.log(`NOTIFICATION subject=${input.subject} to=${input.to}`); // eslint-disable-next-line no-console console.log(`NOTIFICATION_BODY ${input.text}`); return { ok: true, provider: "console" }; } const result = await sendViaWebhook(input, requestContext); if (!result.ok && !provider && !isProduction() && !explicitWebhookUrl) { // eslint-disable-next-line no-console console.log(`NOTIFICATION_FALLBACK subject=${input.subject} to=${input.to}`); // eslint-disable-next-line no-console console.log(`NOTIFICATION_BODY ${input.text}`); return { ok: true, provider: "console" }; } return result.ok ? { ok: true, provider: result.provider } : { ok: false, error: result.error }; }