Add resource

This commit is contained in:
Owen
2026-04-20 17:48:44 -07:00
parent 0a70896080
commit f38069623b
16 changed files with 511 additions and 44 deletions

View File

@@ -18,6 +18,7 @@ import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions
@@ -37,6 +38,11 @@ export const HC_EVENT_TYPES = [
"health_check_unhealthy",
"health_check_toggle"
] as const;
export const RESOURCE_EVENT_TYPES = [
"resource_healthy",
"resource_unhealthy",
"resource_toggle"
] as const;
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -53,7 +59,8 @@ const bodySchema = z
name: z.string().nonempty(),
eventType: z.enum([
...HC_EVENT_TYPES,
...SITE_EVENT_TYPES
...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
]),
enabled: z.boolean().optional().default(true),
cooldownSeconds: z.number().int().nonnegative().optional().default(300),
@@ -63,6 +70,10 @@ const bodySchema = z
.array(z.number().int().positive())
.optional()
.default([]),
resourceIds: z
.array(z.number().int().positive())
.optional()
.default([]),
// Email recipients (flat)
userIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.number()).optional().default([]),
@@ -77,6 +88,9 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
if (isSiteEvent && val.siteIds.length === 0) {
ctx.addIssue({
@@ -110,6 +124,46 @@ const bodySchema = z
path: ["siteIds"]
});
}
if (isResourceEvent && val.resourceIds.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one resourceId is required for resource event types",
path: ["resourceIds"]
});
}
if (isResourceEvent && val.siteIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "siteIds must not be set for resource event types",
path: ["siteIds"]
});
}
if (isResourceEvent && val.healthCheckIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for resource event types",
path: ["healthCheckIds"]
});
}
if (isSiteEvent && val.resourceIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "resourceIds must not be set for site event types",
path: ["resourceIds"]
});
}
if (isHcEvent && val.resourceIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "resourceIds must not be set for health check event types",
path: ["resourceIds"]
});
}
});
export type CreateAlertRuleResponse = {
@@ -169,6 +223,7 @@ export async function createAlertRule(
cooldownSeconds,
siteIds,
healthCheckIds,
resourceIds,
userIds,
roleIds,
emails,
@@ -210,6 +265,16 @@ export async function createAlertRule(
);
}
// Insert resource associations
if (resourceIds.length > 0) {
await db.insert(alertResources).values(
resourceIds.map((resourceId) => ({
alertRuleId: rule.alertRuleId,
resourceId
}))
);
}
// Create the email action pivot row and recipients if any recipients
// were supplied (userIds, roleIds, or raw emails).
const hasRecipients =

View File

@@ -18,6 +18,7 @@ import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions
@@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { WebhookAlertConfig } from "@server/lib/alerts/types";
import { WebhookAlertConfig } from "#private/lib/alerts/types";
const paramsSchema = z
.object({
@@ -50,7 +51,10 @@ export type GetAlertRuleResponse = {
| "site_toggle"
| "health_check_healthy"
| "health_check_unhealthy"
| "health_check_toggle";
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
enabled: boolean;
cooldownSeconds: number;
lastTriggeredAt: number | null;
@@ -58,6 +62,7 @@ export type GetAlertRuleResponse = {
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
recipients: {
recipientId: number;
userId: string | null;
@@ -130,6 +135,12 @@ export async function getAlertRule(
.from(alertHealthChecks)
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
// Fetch resource associations
const resourceRows = await db
.select()
.from(alertResources)
.where(eq(alertResources.alertRuleId, alertRuleId));
// Resolve the single email action row for this rule, then collect all
// recipients into a flat list. The emailAction pivot row is an internal
// implementation detail and is not surfaced to callers.
@@ -177,6 +188,7 @@ export async function getAlertRule(
updatedAt: rule.updatedAt,
siteIds: siteRows.map((r) => r.siteId),
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
resourceIds: resourceRows.map((r) => r.resourceId),
recipients,
webhookActions: webhooks.map((w) => {
let parsedConfig: WebhookAlertConfig | null = null;

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { alertRules, alertSites, alertHealthChecks } from "@server/db";
import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -55,6 +55,7 @@ export type ListAlertRulesResponse = {
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
}[];
pagination: {
total: number;
@@ -138,6 +139,14 @@ export async function listAlertRules(
)
: [];
const resourceRows =
ruleIds.length > 0
? await db
.select()
.from(alertResources)
.where(inArray(alertResources.alertRuleId, ruleIds))
: [];
// Index by alertRuleId for O(1) lookup when building the response
const sitesByRule = new Map<number, number[]>();
for (const row of siteRows) {
@@ -153,6 +162,13 @@ export async function listAlertRules(
healthChecksByRule.set(row.alertRuleId, existing);
}
const resourcesByRule = new Map<number, number[]>();
for (const row of resourceRows) {
const existing = resourcesByRule.get(row.alertRuleId) ?? [];
existing.push(row.resourceId);
resourcesByRule.set(row.alertRuleId, existing);
}
return response<ListAlertRulesResponse>(res, {
data: {
alertRules: list.map((rule) => ({
@@ -167,7 +183,8 @@ export async function listAlertRules(
updatedAt: rule.updatedAt,
siteIds: sitesByRule.get(rule.alertRuleId) ?? [],
healthCheckIds:
healthChecksByRule.get(rule.alertRuleId) ?? []
healthChecksByRule.get(rule.alertRuleId) ?? [],
resourceIds: resourcesByRule.get(rule.alertRuleId) ?? []
})),
pagination: {
total: count,

View File

@@ -18,6 +18,7 @@ import {
alertRules,
alertSites,
alertHealthChecks,
alertResources,
alertEmailActions,
alertEmailRecipients,
alertWebhookActions
@@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { HC_EVENT_TYPES, SITE_EVENT_TYPES } from "./createAlertRule";
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
const paramsSchema = z
.object({
@@ -53,7 +54,8 @@ const bodySchema = z
eventType: z
.enum([
...HC_EVENT_TYPES,
...SITE_EVENT_TYPES
...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
])
.optional(),
enabled: z.boolean().optional(),
@@ -61,6 +63,7 @@ const bodySchema = z
// Source join tables - if provided the full set is replaced
siteIds: z.array(z.number().int().positive()).optional(),
healthCheckIds: z.array(z.number().int().positive()).optional(),
resourceIds: z.array(z.number().int().positive()).optional(),
// Recipient arrays - if any are provided the full recipient set is replaced
userIds: z.array(z.string().nonempty()).optional(),
roleIds: z.array(z.number()).optional(),
@@ -77,6 +80,9 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
ctx.addIssue({
@@ -93,6 +99,22 @@ const bodySchema = z
path: ["siteIds"]
});
}
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "siteIds must not be set for resource event types",
path: ["siteIds"]
});
}
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for resource event types",
path: ["healthCheckIds"]
});
}
});
export type UpdateAlertRuleResponse = {
@@ -168,6 +190,7 @@ export async function updateAlertRule(
cooldownSeconds,
siteIds,
healthCheckIds,
resourceIds,
userIds,
roleIds,
emails,
@@ -227,6 +250,22 @@ export async function updateAlertRule(
}
}
// --- Full-replace resource associations if resourceIds was provided ---
if (resourceIds !== undefined) {
await db
.delete(alertResources)
.where(eq(alertResources.alertRuleId, alertRuleId));
if (resourceIds.length > 0) {
await db.insert(alertResources).values(
resourceIds.map((resourceId) => ({
alertRuleId,
resourceId
}))
);
}
}
// --- Full-replace recipients if any recipient array was provided ---
const recipientsProvided =
userIds !== undefined ||
@@ -324,4 +363,4 @@ export async function updateAlertRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}