mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-16 06:46:37 +00:00
First pass
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
});
|
||||
|
||||
140
server/emails/templates/AlertNotification.tsx
Normal file
140
server/emails/templates/AlertNotification.tsx
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
): { 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: (
|
||||
<span style={{ color: meta.statusColor, fontWeight: 600 }}>
|
||||
{meta.statusLabel}
|
||||
</span>
|
||||
)},
|
||||
{ label: "Time", value: new Date().toUTCString() },
|
||||
...dataItems
|
||||
];
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{meta.previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>{meta.heading}</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>{meta.summary}</EmailText>
|
||||
|
||||
<EmailInfoSection
|
||||
title="Event Details"
|
||||
items={allItems}
|
||||
/>
|
||||
|
||||
<EmailText>
|
||||
Log in to your dashboard to view more details and
|
||||
manage your alert rules.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertNotification;
|
||||
91
server/private/lib/alerts/events/healthCheckEvents.ts
Normal file
91
server/private/lib/alerts/events/healthCheckEvents.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
91
server/private/lib/alerts/events/siteEvents.ts
Normal file
91
server/private/lib/alerts/events/siteEvents.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
19
server/private/lib/alerts/index.ts
Normal file
19
server/private/lib/alerts/index.ts
Normal 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";
|
||||
266
server/private/lib/alerts/processAlerts.ts
Normal file
266
server/private/lib/alerts/processAlerts.ts
Normal 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);
|
||||
}
|
||||
87
server/private/lib/alerts/sendAlertEmail.ts
Normal file
87
server/private/lib/alerts/sendAlertEmail.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
132
server/private/lib/alerts/sendAlertWebhook.ts
Normal file
132
server/private/lib/alerts/sendAlertWebhook.ts
Normal 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;
|
||||
}
|
||||
59
server/private/lib/alerts/types.ts
Normal file
59
server/private/lib/alerts/types.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user