import { env } from "@/env"; import { logger } from "@/lib/logger"; import { prisma } from "@/lib/prisma"; import { sendMail } from "@/lib/services/email"; // === Alert service (AtomCMS → Next.js) =========================================== // // A pure, dependency-free server module for raising operational alerts. A single // sendAlert() call (a) persists a row in alert_logs (prisma.alertLogs delegate), // (b) pushes a Discord embed to DISCORD_WEBHOOK_URL when set, and (c) optionally // emails staff via sendMail() when ALERT_EMAIL is set. // // Every external side-effect is wrapped in try/catch so a failing webhook, dead // SMTP host, or unreachable DB never throws into the caller (which is usually a // background path: emulator health checks, DDoS detection, etc.). Uses the global // fetch (Node 18+/Next 16) — no extra packages. // // NOTE: DISCORD_WEBHOOK_URL and ALERT_EMAIL are NOT declared in src/env.ts, so we // read them from process.env directly with safe fallbacks (the features simply // no-op when their env var is unset). Add them to env.ts later if you want them // validated at boot. export type AlertSeverity = | "info" | "notice" | "warning" | "error" | "critical"; export interface SendAlertInput { /** Machine-readable category, e.g. "emulator", "security", "ddos". */ type: string; /** Free-text severity; drives Discord embed colour + email subject prefix. */ severity: AlertSeverity | string; /** Human-readable message body. */ message: string; /** Optional structured payload stored verbatim in alert_logs.context (JSON). */ context?: Record; } export interface SendAlertResult { logged: boolean; sentViaDiscord: boolean; sentViaEmail: boolean; } // Discord embed sidebar colours (decimal RGB) keyed by normalised severity. const DISCORD_COLORS: Record = { critical: 0xc0392b, error: 0xe74c3c, danger: 0xe74c3c, warning: 0xf39c12, warn: 0xf39c12, success: 0x2ecc71, info: 0x3498db, notice: 0x9b59b6, }; function severityColor(severity: string): number { return DISCORD_COLORS[severity.toLowerCase()] ?? 0x95a5a6; } function discordWebhookUrl(): string | undefined { const url = process.env.DISCORD_WEBHOOK_URL?.trim(); return url ? url : undefined; } function alertEmail(): string | undefined { const addr = process.env.ALERT_EMAIL?.trim(); return addr ? addr : undefined; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Post a Discord embed to DISCORD_WEBHOOK_URL. Returns false (without throwing) * when the webhook is unset, the request fails, or Discord returns non-2xx. */ async function postDiscord(input: SendAlertInput): Promise { const url = discordWebhookUrl(); if (!url) return false; const fields = input.context ? Object.entries(input.context) .slice(0, 10) .map(([name, value]) => ({ name: String(name).slice(0, 256) || "​", value: String(value ?? "").slice(0, 1024) || "​", inline: true, })) : undefined; const body = { username: `${env.HOTEL_NAME} Alerts`, embeds: [ { title: `[${String(input.severity).toUpperCase()}] ${input.type}`.slice( 0, 256, ), description: input.message.slice(0, 4096), color: severityColor(input.severity), timestamp: new Date().toISOString(), ...(fields?.length ? { fields } : {}), footer: { text: env.HOTEL_NAME }, }, ], }; try { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { logger.error("Discord webhook returned non-OK status", { module: "alert", status: res.status, }); return false; } return true; } catch (e) { logger.error("Discord webhook failed", { module: "alert", error: (e as Error).message, }); return false; } } /** * Email the alert to ALERT_EMAIL via sendMail(). Returns false (without throwing) * when ALERT_EMAIL is unset, SMTP isn't configured, or sending fails. sendMail * already swallows its own errors, but we guard defensively anyway. */ async function emailStaff(input: SendAlertInput): Promise { const to = alertEmail(); if (!to) return false; const subject = `[${env.HOTEL_NAME}] ${String(input.severity).toUpperCase()} · ${input.type}`; const contextRows = input.context ? Object.entries(input.context) .map( ([k, v]) => `${escapeHtml(String(k))}` + `${escapeHtml(String(v ?? ""))}`, ) .join("") : ""; const html = `

${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}

` + `

${escapeHtml(input.message)}

` + (contextRows ? `${contextRows}
` : "") + `

` + `Sent by ${escapeHtml(env.HOTEL_NAME)} · view alerts

`; try { return await sendMail(to, subject, html); } catch (e) { logger.error("Staff email failed", { module: "alert", error: (e as Error).message, }); return false; } } /** * Raise an operational alert: persist to alert_logs and fan out to Discord + * email. Never throws — each side-effect is isolated; a failure in one channel * does not block the others. The returned result reports which channels * succeeded (also reflected in the alert_logs row's sent_via_* flags). */ export async function sendAlert( input: SendAlertInput, ): Promise { // Fan out Discord + email first so we can record their outcome on the row. const [sentViaDiscord, sentViaEmail] = await Promise.all([ postDiscord(input), emailStaff(input), ]); let logged = false; try { await prisma.alertLogs.create({ data: { type: input.type.slice(0, 255), severity: String(input.severity).slice(0, 255), message: input.message, context: input.context ? (input.context as object) : undefined, sentViaDiscord, sentViaEmail, isRead: false, createdAt: new Date(), updatedAt: new Date(), }, }); logged = true; } catch (e) { // DB unreachable / schema drift: keep the alert best-effort. We already // notified Discord/email above, so the alert isn't lost. logger.error("Failed to persist alert_logs row", { module: "alert", error: (e as Error).message, }); } return { logged, sentViaDiscord, sentViaEmail }; } // === Helpers ===================================================================== /** * Raise a CRITICAL alert that the Arcturus emulator appears to be offline * (e.g. raised by a health-check cron when the RCON socket can't connect). */ export function emulatorOffline(detail?: string): Promise { return sendAlert({ type: "emulator", severity: "critical", message: detail ? `Emulator appears offline: ${detail}` : "Emulator appears offline — RCON connection could not be established.", context: { rconHost: env.RCON_HOST, rconPort: env.RCON_PORT, ...(detail ? { detail } : {}), }, }); } /** * Raise a WARNING alert that a possible DDoS / abusive request pattern was * detected from a single IP (count = requests seen in the sampling window). */ export function ddosDetected( ip: string, count: number, ): Promise { return sendAlert({ type: "ddos", severity: count >= 1000 ? "critical" : "warning", message: `Possible DDoS detected from ${ip} — ${count} requests in window.`, context: { ip, count }, }); }