First pass

This commit is contained in:
Owen
2026-04-14 21:58:36 -07:00
parent 33182bcf85
commit 7d50703c26
10 changed files with 887 additions and 2 deletions

View File

@@ -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<string, unknown>
): Promise<void> {
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<string, unknown>
): Promise<void> {
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
);
}
}

View File

@@ -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<string, unknown>
): Promise<void> {
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<string, unknown>
): Promise<void> {
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
);
}
}

View File

@@ -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";

View File

@@ -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<void> {
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<void> {
// ------------------------------------------------------------------
// 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<string[]> {
const rows = await db
.select()
.from(alertEmailRecipients)
.where(eq(alertEmailRecipients.emailActionId, emailActionId));
const emailSet = new Set<string>();
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);
}

View File

@@ -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<void> {
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";
}
}
}

View File

@@ -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<void> {
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<string, string> {
const headers: Record<string, string> = {
"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;
}

View File

@@ -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<string, unknown>;
}