chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
154
lib/notification.ts
Normal file
154
lib/notification.ts
Normal file
@ -0,0 +1,154 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user