155 lines
4.2 KiB
TypeScript
155 lines
4.2 KiB
TypeScript
import { getRequestAuditContext } from "@/lib/audit";
|
|
|
|
export type TransactionalNotificationInput = {
|
|
to: string;
|
|
subject: string;
|
|
text: string;
|
|
html?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
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<string, string> = {
|
|
"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<TransactionalNotificationResult> {
|
|
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 };
|
|
}
|