Files
EpicNext-Cms/src/lib/services/alert.ts
T
openhands df38dccbf1
Local Build and Deploy / deploy (push) Failing after 46s
style: format code biome
2026-07-13 21:57:41 +02:00

257 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
}
export interface SendAlertResult {
logged: boolean;
sentViaDiscord: boolean;
sentViaEmail: boolean;
}
// Discord embed sidebar colours (decimal RGB) keyed by normalised severity.
const DISCORD_COLORS: Record<string, number> = {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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<boolean> {
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<boolean> {
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]) =>
`<tr><td style="padding:4px 10px;font-weight:600">${escapeHtml(String(k))}</td>` +
`<td style="padding:4px 10px">${escapeHtml(String(v ?? ""))}</td></tr>`,
)
.join("")
: "";
const html =
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
(contextRows
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
: "") +
`<p style="margin-top:16px;color:#888;font-size:12px">` +
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
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<SendAlertResult> {
// 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<SendAlertResult> {
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<SendAlertResult> {
return sendAlert({
type: "ddos",
severity: count >= 1000 ? "critical" : "warning",
message: `Possible DDoS detected from ${ip} — ${count} requests in window.`,
context: { ip, count },
});
}