diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index d0bd05fb5..011373065 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -519,7 +519,7 @@ export const alertWebhookActions = pgTable("alertWebhookActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - secret: varchar("secret", { length: 255 }), // for HMAC signature validation + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: boolean("enabled").notNull().default(true), lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 180a337c1..e0a3aed6f 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -508,7 +508,7 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - secret: text("secret"), + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), lastSentAt: integer("lastSentAt") }); diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx new file mode 100644 index 000000000..c01a99f3e --- /dev/null +++ b/server/emails/templates/AlertNotification.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +interface Props { + eventType: AlertEventType; + orgId: string; + data: Record; +} + +function getEventMeta(eventType: AlertEventType): { + heading: string; + previewText: string; + summary: string; + statusLabel: string; + statusColor: string; +} { + switch (eventType) { + case "site_online": + return { + heading: "Site Back Online", + previewText: "A site in your organization is back online.", + summary: + "Good news – a site in your organization has come back online and is now reachable.", + statusLabel: "Online", + statusColor: "#16a34a" + }; + case "site_offline": + return { + heading: "Site Offline", + previewText: "A site in your organization has gone offline.", + summary: + "A site in your organization has gone offline and is no longer reachable. Please investigate as soon as possible.", + statusLabel: "Offline", + statusColor: "#dc2626" + }; + case "health_check_healthy": + return { + heading: "Health Check Recovered", + previewText: + "A health check in your organization is now healthy.", + summary: + "A health check in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "health_check_not_healthy": + return { + heading: "Health Check Failing", + previewText: + "A health check in your organization is not healthy.", + summary: + "A health check in your organization is currently failing. Please review the details below and take action if needed.", + statusLabel: "Not Healthy", + statusColor: "#dc2626" + }; + } +} + +function formatDataItems( + data: Record +): { label: string; value: React.ReactNode }[] { + return Object.entries(data) + .filter(([key]) => key !== "orgId") + .map(([key, value]) => ({ + label: key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (s) => s.toUpperCase()) + .trim(), + value: String(value ?? "—") + })); +} + +export const AlertNotification = ({ eventType, orgId, data }: Props) => { + const meta = getEventMeta(eventType); + const dataItems = formatDataItems(data); + + const allItems: { label: string; value: React.ReactNode }[] = [ + { label: "Organization", value: orgId }, + { label: "Status", value: ( + + {meta.statusLabel} + + )}, + { label: "Time", value: new Date().toUTCString() }, + ...dataItems + ]; + + return ( + + + {meta.previewText} + + + + + + {meta.heading} + + Hi there, + + {meta.summary} + + + + + Log in to your dashboard to view more details and + manage your alert rules. + + + + + + + + + + ); +}; + +export default AlertNotification; \ No newline at end of file diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts new file mode 100644 index 000000000..0adb2441b --- /dev/null +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -0,0 +1,91 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import { processAlerts } from "../processAlerts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `health_check_healthy` alert for the given health check. + * + * Call this after a previously-failing health check has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +/** + * Fire a `health_check_not_healthy` alert for the given health check. + * + * Call this after a health check has been detected as failing so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckNotHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_not_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts new file mode 100644 index 000000000..7074542cf --- /dev/null +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -0,0 +1,91 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import { processAlerts } from "../processAlerts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `site_online` alert for the given site. + * + * Call this after the site has been confirmed reachable / connected so that + * any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireSiteOnlineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "site_online", + orgId, + siteId, + data: { + siteId, + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} + +/** + * Fire a `site_offline` alert for the given site. + * + * Call this after the site has been detected as unreachable / disconnected so + * that any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireSiteOfflineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "site_offline", + orgId, + siteId, + data: { + siteId, + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/index.ts b/server/private/lib/alerts/index.ts new file mode 100644 index 000000000..e529533e6 --- /dev/null +++ b/server/private/lib/alerts/index.ts @@ -0,0 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./types"; +export * from "./processAlerts"; +export * from "./sendAlertWebhook"; +export * from "./sendAlertEmail"; +export * from "./events/siteEvents"; +export * from "./events/healthCheckEvents"; \ No newline at end of file diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts new file mode 100644 index 000000000..1a6fd242f --- /dev/null +++ b/server/private/lib/alerts/processAlerts.ts @@ -0,0 +1,266 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { and, eq, isNull, or } from "drizzle-orm"; +import { db } from "@server/db"; +import { + alertRules, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions, + userOrgRoles, + users +} from "@server/db"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import { AlertContext, WebhookAlertConfig } from "./types"; +import { sendAlertWebhook } from "./sendAlertWebhook"; +import { sendAlertEmail } from "./sendAlertEmail"; + +/** + * Core alert processing pipeline. + * + * Given an `AlertContext`, this function: + * 1. Finds all enabled `alertRules` whose `eventType` matches and whose + * `siteId` / `healthCheckId` matches (or is null, meaning "all"). + * 2. Applies per-rule cooldown gating. + * 3. Dispatches emails and webhook POSTs for every attached action. + * 4. Updates `lastTriggeredAt` and `lastSentAt` timestamps. + */ +export async function processAlerts(context: AlertContext): Promise { + const now = Date.now(); + + // ------------------------------------------------------------------ + // 1. Find matching alert rules + // ------------------------------------------------------------------ + const siteCondition = + context.siteId != null + ? or( + eq(alertRules.siteId, context.siteId), + isNull(alertRules.siteId) + ) + : isNull(alertRules.siteId); + + const healthCheckCondition = + context.healthCheckId != null + ? or( + eq(alertRules.healthCheckId, context.healthCheckId), + isNull(alertRules.healthCheckId) + ) + : isNull(alertRules.healthCheckId); + + const rules = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.orgId, context.orgId), + eq(alertRules.eventType, context.eventType), + eq(alertRules.enabled, true), + // Apply the right scope filter based on event type + context.siteId != null ? siteCondition : healthCheckCondition + ) + ); + + if (rules.length === 0) { + logger.debug( + `processAlerts: no matching rules for event "${context.eventType}" in org "${context.orgId}"` + ); + return; + } + + for (const rule of rules) { + try { + await processRule(rule, context, now); + } catch (err) { + logger.error( + `processAlerts: error processing rule ${rule.alertRuleId} for event "${context.eventType}"`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Per-rule processing +// --------------------------------------------------------------------------- + +async function processRule( + rule: typeof alertRules.$inferSelect, + context: AlertContext, + now: number +): Promise { + // ------------------------------------------------------------------ + // 2. Cooldown check + // ------------------------------------------------------------------ + if ( + rule.lastTriggeredAt != null && + now - rule.lastTriggeredAt < rule.cooldownSeconds * 1000 + ) { + const remainingSeconds = Math.ceil( + (rule.cooldownSeconds * 1000 - (now - rule.lastTriggeredAt)) / 1000 + ); + logger.debug( + `processAlerts: rule ${rule.alertRuleId} is in cooldown – ${remainingSeconds}s remaining` + ); + return; + } + + // ------------------------------------------------------------------ + // 3. Mark rule as triggered (optimistic update – before sending so we + // don't re-trigger if the send is slow) + // ------------------------------------------------------------------ + await db + .update(alertRules) + .set({ lastTriggeredAt: now }) + .where(eq(alertRules.alertRuleId, rule.alertRuleId)); + + // ------------------------------------------------------------------ + // 4. Process email actions + // ------------------------------------------------------------------ + const emailActions = await db + .select() + .from(alertEmailActions) + .where( + and( + eq(alertEmailActions.alertRuleId, rule.alertRuleId), + eq(alertEmailActions.enabled, true) + ) + ); + + for (const action of emailActions) { + try { + const recipients = await resolveEmailRecipients(action.emailActionId); + if (recipients.length > 0) { + await sendAlertEmail(recipients, context); + await db + .update(alertEmailActions) + .set({ lastSentAt: now }) + .where( + eq(alertEmailActions.emailActionId, action.emailActionId) + ); + } + } catch (err) { + logger.error( + `processAlerts: failed to send alert email for action ${action.emailActionId}`, + err + ); + } + } + + // ------------------------------------------------------------------ + // 5. Process webhook actions + // ------------------------------------------------------------------ + const webhookActions = await db + .select() + .from(alertWebhookActions) + .where( + and( + eq(alertWebhookActions.alertRuleId, rule.alertRuleId), + eq(alertWebhookActions.enabled, true) + ) + ); + + const serverSecret = config.getRawConfig().server.secret!; + + for (const action of webhookActions) { + try { + let webhookConfig: WebhookAlertConfig = { authType: "none" }; + + if (action.config) { + try { + const decrypted = decrypt(action.config, serverSecret); + webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch (err) { + logger.error( + `processAlerts: failed to decrypt webhook config for action ${action.webhookActionId}`, + err + ); + continue; + } + } + + await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + await db + .update(alertWebhookActions) + .set({ lastSentAt: now }) + .where( + eq( + alertWebhookActions.webhookActionId, + action.webhookActionId + ) + ); + } catch (err) { + logger.error( + `processAlerts: failed to send alert webhook for action ${action.webhookActionId}`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Email recipient resolution +// --------------------------------------------------------------------------- + +/** + * Resolves all email addresses for a given `emailActionId`. + * + * Recipients may be: + * - Direct users (by `userId`) + * - All users in a role (by `roleId`, resolved via `userOrgRoles`) + * - Direct external email addresses + */ +async function resolveEmailRecipients(emailActionId: number): Promise { + const rows = await db + .select() + .from(alertEmailRecipients) + .where(eq(alertEmailRecipients.emailActionId, emailActionId)); + + const emailSet = new Set(); + + for (const row of rows) { + if (row.email) { + emailSet.add(row.email); + } + + if (row.userId) { + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.userId, row.userId)) + .limit(1); + if (user?.email) { + emailSet.add(user.email); + } + } + + if (row.roleId) { + // Find all users with this role via userOrgRoles + const roleUsers = await db + .select({ email: users.email }) + .from(userOrgRoles) + .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + .where(eq(userOrgRoles.roleId, Number(row.roleId))); + + for (const u of roleUsers) { + if (u.email) { + emailSet.add(u.email); + } + } + } + } + + return Array.from(emailSet); +} \ No newline at end of file diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts new file mode 100644 index 000000000..e119b5eb7 --- /dev/null +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -0,0 +1,87 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { sendEmail } from "@server/emails"; +import AlertNotification from "@server/emails/templates/AlertNotification"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { AlertContext } from "./types"; + +/** + * Sends an alert notification email to every address in `recipients`. + * + * Each recipient receives an individual email (no BCC list) so that delivery + * failures for one address do not affect the others. Failures per recipient + * are logged and swallowed – the caller only sees an error if something goes + * wrong before the send loop. + */ +export async function sendAlertEmail( + recipients: string[], + context: AlertContext +): Promise { + if (recipients.length === 0) { + return; + } + + const from = config.getNoReplyEmail(); + const subject = buildSubject(context); + + for (const to of recipients) { + try { + await sendEmail( + AlertNotification({ + eventType: context.eventType, + orgId: context.orgId, + data: context.data + }), + { + from, + to, + subject + } + ); + logger.debug( + `Alert email sent to "${to}" for event "${context.eventType}"` + ); + } catch (err) { + logger.error( + `sendAlertEmail: failed to send alert email to "${to}" for event "${context.eventType}"`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildSubject(context: AlertContext): string { + switch (context.eventType) { + case "site_online": + return "[Alert] Site Back Online"; + case "site_offline": + return "[Alert] Site Offline"; + case "health_check_healthy": + return "[Alert] Health Check Recovered"; + case "health_check_not_healthy": + return "[Alert] Health Check Failing"; + default: { + // Exhaustiveness fallback – should never be reached with a + // well-typed caller, but keeps runtime behaviour predictable. + const _exhaustive: never = context.eventType; + void _exhaustive; + return "[Alert] Event Notification"; + } + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts new file mode 100644 index 000000000..38d3c514a --- /dev/null +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -0,0 +1,132 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import { AlertContext, WebhookAlertConfig } from "./types"; + +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Sends a single webhook POST for an alert event. + * + * The payload shape is: + * ```json + * { + * "event": "site_online", + * "timestamp": "2024-01-01T00:00:00.000Z", + * "data": { ... } + * } + * ``` + * + * Authentication headers are applied according to `config.authType`, + * mirroring the same strategies supported by HttpLogDestination: + * none | bearer | basic | custom. + */ +export async function sendAlertWebhook( + url: string, + webhookConfig: WebhookAlertConfig, + context: AlertContext +): Promise { + const payload = { + event: context.eventType, + timestamp: new Date().toISOString(), + data: { + orgId: context.orgId, + ...context.data + } + }; + + const body = JSON.stringify(payload); + const headers = buildHeaders(webhookConfig); + + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers, + body, + signal: controller.signal + }); + } catch (err: unknown) { + const isAbort = err instanceof Error && err.name === "AbortError"; + if (isAbort) { + throw new Error( + `Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Alert webhook: request to "${url}" failed – ${msg}`); + } finally { + clearTimeout(timeoutHandle); + } + + if (!response.ok) { + let snippet = ""; + try { + const text = await response.text(); + snippet = text.slice(0, 300); + } catch { + // best-effort + } + throw new Error( + `Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` + + (snippet ? ` – ${snippet}` : "") + ); + } + + logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`); +} + +// --------------------------------------------------------------------------- +// Header construction (mirrors HttpLogDestination.buildHeaders) +// --------------------------------------------------------------------------- + +function buildHeaders(webhookConfig: WebhookAlertConfig): Record { + const headers: Record = { + "Content-Type": "application/json" + }; + + switch (webhookConfig.authType) { + case "bearer": { + const token = webhookConfig.bearerToken?.trim(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + break; + } + case "basic": { + const creds = webhookConfig.basicCredentials?.trim(); + if (creds) { + const encoded = Buffer.from(creds).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } + break; + } + case "custom": { + const name = webhookConfig.customHeaderName?.trim(); + const value = webhookConfig.customHeaderValue ?? ""; + if (name) { + headers[name] = value; + } + break; + } + case "none": + default: + break; + } + + return headers; +} \ No newline at end of file diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts new file mode 100644 index 000000000..d0e91ea8a --- /dev/null +++ b/server/private/lib/alerts/types.ts @@ -0,0 +1,59 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +// --------------------------------------------------------------------------- +// Alert event types +// --------------------------------------------------------------------------- + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +// --------------------------------------------------------------------------- +// Webhook authentication config (stored as encrypted JSON in the DB) +// --------------------------------------------------------------------------- + +export type WebhookAuthType = "none" | "bearer" | "basic" | "custom"; + +/** + * Stored as an encrypted JSON blob in `alertWebhookActions.config`. + */ +export interface WebhookAlertConfig { + /** Authentication strategy for the webhook endpoint */ + authType: WebhookAuthType; + /** Bearer token – used when authType === "bearer" */ + bearerToken?: string; + /** Basic credentials – "username:password" – used when authType === "basic" */ + basicCredentials?: string; + /** Custom header name – used when authType === "custom" */ + customHeaderName?: string; + /** Custom header value – used when authType === "custom" */ + customHeaderValue?: string; +} + +// --------------------------------------------------------------------------- +// Internal alert event passed through the processing pipeline +// --------------------------------------------------------------------------- + +export interface AlertContext { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} \ No newline at end of file