From bf64e226d3d9fb7c3bbdcc85cfaf7f9f2b1c528e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:58:33 -0700 Subject: [PATCH] Many to one on sites and health checks --- server/db/pg/schema/privateSchema.ts | 47 +++++-- server/db/sqlite/schema/privateSchema.ts | 25 +++- .../routers/alertRule/createAlertRule.ts | 130 ++++++++++++++---- .../private/routers/alertRule/getAlertRule.ts | 23 +++- .../routers/alertRule/listAlertRules.ts | 62 ++++++++- .../routers/alertRule/updateAlertRule.ts | 125 ++++++++++++----- 6 files changed, 326 insertions(+), 86 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 011373065..9007013b1 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -480,18 +480,33 @@ export const alertRules = pgTable("alertRules", { >() .notNull(), // Nullable depending on eventType - siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), - healthCheckId: integer("healthCheckId").references( - () => targetHealthCheck.targetHealthCheckId, - { onDelete: "cascade" } - ), enabled: boolean("enabled").notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), - lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable + lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() }); +export const alertSites = pgTable("alertSites", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }) +}); + +export const alertHealthChecks = pgTable("alertHealthChecks", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId") + .notNull() + .references(() => targetHealthCheck.targetHealthCheckId, { + onDelete: "cascade" + }) +}); + // Separating channels by type avoids the mixed-shape problem entirely export const alertEmailActions = pgTable("alertEmailActions", { emailActionId: serial("emailActionId").primaryKey(), @@ -499,18 +514,24 @@ export const alertEmailActions = pgTable("alertEmailActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), enabled: boolean("enabled").notNull().default(true), - lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); export const alertEmailRecipients = pgTable("alertEmailRecipients", { recipientId: serial("recipientId").primaryKey(), emailActionId: integer("emailActionId") .notNull() - .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), + .references(() => alertEmailActions.emailActionId, { + onDelete: "cascade" + }), // At least one of these should be set - enforced at app level - userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }), - email: varchar("email", { length: 255 }) // external emails not tied to a user + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + roleId: varchar("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), + email: varchar("email", { length: 255 }) // external emails not tied to a user }); export const alertWebhookActions = pgTable("alertWebhookActions", { @@ -519,9 +540,9 @@ export const alertWebhookActions = pgTable("alertWebhookActions", { .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - config: text("config"), // encrypted JSON with auth config (authType, credentials) + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: boolean("enabled").notNull().default(true), - lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable }); export type Approval = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index e0a3aed6f..318a094dd 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -471,11 +471,6 @@ export const alertRules = sqliteTable("alertRules", { | "health_check_not_healthy" >() .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), - healthCheckId: integer("healthCheckId").references( - () => targetHealthCheck.targetHealthCheckId, - { onDelete: "cascade" } - ), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), lastTriggeredAt: integer("lastTriggeredAt"), @@ -483,6 +478,26 @@ export const alertRules = sqliteTable("alertRules", { updatedAt: integer("updatedAt").notNull() }); +export const alertSites = sqliteTable("alertSites", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }) +}); + +export const alertHealthChecks = sqliteTable("alertHealthChecks", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId") + .notNull() + .references(() => targetHealthCheck.targetHealthCheckId, { + onDelete: "cascade" + }) +}); + export const alertEmailActions = sqliteTable("alertEmailActions", { emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), alertRuleId: integer("alertRuleId") diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 20ce52492..014a62956 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -16,6 +16,8 @@ import { z } from "zod"; import { db } from "@server/db"; import { alertRules, + alertSites, + alertHealthChecks, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -27,6 +29,12 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; +const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_not_healthy" +] as const; + const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -37,23 +45,73 @@ const webhookActionSchema = z.strictObject({ enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ - name: z.string().nonempty(), - eventType: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" - ]), - siteId: z.number().int().optional(), - healthCheckId: z.number().int().optional(), - enabled: z.boolean().optional().default(true), - cooldownSeconds: z.number().int().nonnegative().optional().default(300), - userIds: z.array(z.string().nonempty()).optional().default([]), - roleIds: z.array(z.string().nonempty()).optional().default([]), - emails: z.array(z.string().email()).optional().default([]), - webhookActions: z.array(webhookActionSchema).optional().default([]) -}); +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + eventType: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_not_healthy" + ]), + enabled: z.boolean().optional().default(true), + cooldownSeconds: z.number().int().nonnegative().optional().default(300), + // Source join tables - which is required depends on eventType + siteIds: z.array(z.number().int().positive()).optional().default([]), + healthCheckIds: z + .array(z.number().int().positive()) + .optional() + .default([]), + // Email recipients (flat) + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.string().nonempty()).optional().default([]), + emails: z.array(z.string().email()).optional().default([]), + // Webhook actions + webhookActions: z.array(webhookActionSchema).optional().default([]) + }) + .superRefine((val, ctx) => { + const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + + if (isSiteEvent && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one siteId is required for site event types", + path: ["siteIds"] + }); + } + + if (isHcEvent && val.healthCheckIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one healthCheckId is required for health check event types", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for site event types", + path: ["healthCheckIds"] + }); + } + + if (isHcEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "siteIds must not be set for health check event types", + path: ["siteIds"] + }); + } + }); export type CreateAlertRuleResponse = { alertRuleId: number; @@ -108,10 +166,10 @@ export async function createAlertRule( const { name, eventType, - siteId, - healthCheckId, enabled, cooldownSeconds, + siteIds, + healthCheckIds, userIds, roleIds, emails, @@ -126,8 +184,6 @@ export async function createAlertRule( orgId, name, eventType, - siteId: siteId ?? null, - healthCheckId: healthCheckId ?? null, enabled, cooldownSeconds, createdAt: now, @@ -135,6 +191,26 @@ export async function createAlertRule( }) .returning(); + // Insert site associations + if (siteIds.length > 0) { + await db.insert(alertSites).values( + siteIds.map((siteId) => ({ + alertRuleId: rule.alertRuleId, + siteId + })) + ); + } + + // Insert health check associations + if (healthCheckIds.length > 0) { + await db.insert(alertHealthChecks).values( + healthCheckIds.map((healthCheckId) => ({ + alertRuleId: rule.alertRuleId, + healthCheckId + })) + ); + } + // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = @@ -150,19 +226,19 @@ export async function createAlertRule( ...userIds.map((userId) => ({ emailActionId: emailActionRow.emailActionId, userId, - roleId: null, - email: null + roleId: null as string | null, + email: null as string | null })), ...roleIds.map((roleId) => ({ emailActionId: emailActionRow.emailActionId, - userId: null, + userId: null as string | null, roleId, - email: null + email: null as string | null })), ...emails.map((email) => ({ emailActionId: emailActionRow.emailActionId, - userId: null, - roleId: null, + userId: null as string | null, + roleId: null as string | null, email })) ]; diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 72c6e1df5..9a19c70be 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -16,6 +16,8 @@ import { z } from "zod"; import { db } from "@server/db"; import { alertRules, + alertSites, + alertHealthChecks, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -44,13 +46,13 @@ export type GetAlertRuleResponse = { | "site_offline" | "health_check_healthy" | "health_check_not_healthy"; - siteId: number | null; - healthCheckId: number | null; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; createdAt: number; updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; recipients: { recipientId: number; userId: string | null; @@ -110,6 +112,18 @@ export async function getAlertRule( ); } + // Fetch site associations + const siteRows = await db + .select() + .from(alertSites) + .where(eq(alertSites.alertRuleId, alertRuleId)); + + // Fetch health check associations + const healthCheckRows = await db + .select() + .from(alertHealthChecks) + .where(eq(alertHealthChecks.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. @@ -138,6 +152,7 @@ export async function getAlertRule( })); } + // Fetch webhook actions const webhooks = await db .select() .from(alertWebhookActions) @@ -149,13 +164,13 @@ export async function getAlertRule( orgId: rule.orgId, name: rule.name, eventType: rule.eventType, - siteId: rule.siteId ?? null, - healthCheckId: rule.healthCheckId ?? null, enabled: rule.enabled, cooldownSeconds: rule.cooldownSeconds, lastTriggeredAt: rule.lastTriggeredAt ?? null, createdAt: rule.createdAt, updatedAt: rule.updatedAt, + siteIds: siteRows.map((r) => r.siteId), + healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), recipients, webhookActions: webhooks.map((w) => ({ webhookActionId: w.webhookActionId, diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index faf164d73..c0729e75b 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025-2026 Fossorial, Inc. + * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -14,14 +14,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules } from "@server/db"; +import { alertRules, alertSites, alertHealthChecks } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -48,13 +48,13 @@ export type ListAlertRulesResponse = { orgId: string; name: string; eventType: string; - siteId: number | null; - healthCheckId: number | null; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; createdAt: number; updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; }[]; pagination: { total: number; @@ -116,9 +116,59 @@ export async function listAlertRules( .from(alertRules) .where(eq(alertRules.orgId, orgId)); + // Batch-fetch site and health-check associations for all returned rules + // in two queries rather than N+1 individual lookups. + const ruleIds = list.map((r) => r.alertRuleId); + + const siteRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertSites) + .where(inArray(alertSites.alertRuleId, ruleIds)) + : []; + + const healthCheckRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertHealthChecks) + .where( + inArray(alertHealthChecks.alertRuleId, ruleIds) + ) + : []; + + // Index by alertRuleId for O(1) lookup when building the response + const sitesByRule = new Map(); + for (const row of siteRows) { + const existing = sitesByRule.get(row.alertRuleId) ?? []; + existing.push(row.siteId); + sitesByRule.set(row.alertRuleId, existing); + } + + const healthChecksByRule = new Map(); + for (const row of healthCheckRows) { + const existing = healthChecksByRule.get(row.alertRuleId) ?? []; + existing.push(row.healthCheckId); + healthChecksByRule.set(row.alertRuleId, existing); + } + return response(res, { data: { - alertRules: list, + alertRules: list.map((rule) => ({ + alertRuleId: rule.alertRuleId, + orgId: rule.orgId, + name: rule.name, + eventType: rule.eventType, + enabled: rule.enabled, + cooldownSeconds: rule.cooldownSeconds, + lastTriggeredAt: rule.lastTriggeredAt ?? null, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + siteIds: sitesByRule.get(rule.alertRuleId) ?? [], + healthCheckIds: + healthChecksByRule.get(rule.alertRuleId) ?? [] + })), pagination: { total: count, limit, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 05116d3b8..6c7bc14d7 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -16,6 +16,8 @@ import { z } from "zod"; import { db } from "@server/db"; import { alertRules, + alertSites, + alertHealthChecks, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -28,6 +30,12 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; +const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; +const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_not_healthy" +] as const; + const paramsSchema = z .object({ orgId: z.string().nonempty(), @@ -41,28 +49,56 @@ const webhookActionSchema = z.strictObject({ enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ - // Alert rule fields - all optional for partial updates - name: z.string().nonempty().optional(), - eventType: z - .enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" - ]) - .optional(), - siteId: z.number().int().nullable().optional(), - healthCheckId: z.number().int().nullable().optional(), - enabled: z.boolean().optional(), - cooldownSeconds: z.number().int().nonnegative().optional(), - // Recipient arrays - if any are provided the full recipient set is replaced - userIds: z.array(z.string().nonempty()).optional(), - roleIds: z.array(z.string().nonempty()).optional(), - emails: z.array(z.string().email()).optional(), - // Webhook actions - if provided the full webhook set is replaced - webhookActions: z.array(webhookActionSchema).optional() -}); +const bodySchema = z + .strictObject({ + // Alert rule fields - all optional for partial updates + name: z.string().nonempty().optional(), + eventType: z + .enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_not_healthy" + ]) + .optional(), + enabled: z.boolean().optional(), + cooldownSeconds: z.number().int().nonnegative().optional(), + // 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(), + // Recipient arrays - if any are provided the full recipient set is replaced + userIds: z.array(z.string().nonempty()).optional(), + roleIds: z.array(z.string().nonempty()).optional(), + emails: z.array(z.string().email()).optional(), + // Webhook actions - if provided the full webhook set is replaced + webhookActions: z.array(webhookActionSchema).optional() + }) + .superRefine((val, ctx) => { + if (!val.eventType) return; + + const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + + if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for site event types", + path: ["healthCheckIds"] + }); + } + + if (isHcEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for health check event types", + path: ["siteIds"] + }); + } + }); export type UpdateAlertRuleResponse = { alertRuleId: number; @@ -133,10 +169,10 @@ export async function updateAlertRule( const { name, eventType, - siteId, - healthCheckId, enabled, cooldownSeconds, + siteIds, + healthCheckIds, userIds, roleIds, emails, @@ -150,10 +186,9 @@ export async function updateAlertRule( if (name !== undefined) updateData.name = name; if (eventType !== undefined) updateData.eventType = eventType; - if (siteId !== undefined) updateData.siteId = siteId; - if (healthCheckId !== undefined) updateData.healthCheckId = healthCheckId; if (enabled !== undefined) updateData.enabled = enabled; - if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + if (cooldownSeconds !== undefined) + updateData.cooldownSeconds = cooldownSeconds; await db .update(alertRules) @@ -165,6 +200,38 @@ export async function updateAlertRule( ) ); + // --- Full-replace site associations if siteIds was provided --- + if (siteIds !== undefined) { + await db + .delete(alertSites) + .where(eq(alertSites.alertRuleId, alertRuleId)); + + if (siteIds.length > 0) { + await db.insert(alertSites).values( + siteIds.map((siteId) => ({ + alertRuleId, + siteId + })) + ); + } + } + + // --- Full-replace health check associations if healthCheckIds was provided --- + if (healthCheckIds !== undefined) { + await db + .delete(alertHealthChecks) + .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + + if (healthCheckIds.length > 0) { + await db.insert(alertHealthChecks).values( + healthCheckIds.map((healthCheckId) => ({ + alertRuleId, + healthCheckId + })) + ); + } + } + // --- Full-replace recipients if any recipient array was provided --- const recipientsProvided = userIds !== undefined || @@ -172,7 +239,6 @@ export async function updateAlertRule( emails !== undefined; if (recipientsProvided) { - // Build the flat list of recipient rows to insert const newRecipients = [ ...(userIds ?? []).map((userId) => ({ userId, @@ -191,14 +257,12 @@ export async function updateAlertRule( })) ]; - // Find or create the single emailAction row for this rule const [existingEmailAction] = await db .select() .from(alertEmailActions) .where(eq(alertEmailActions.alertRuleId, alertRuleId)); if (existingEmailAction) { - // Delete all current recipients then re-insert await db .delete(alertEmailRecipients) .where( @@ -217,7 +281,6 @@ export async function updateAlertRule( ); } } else if (newRecipients.length > 0) { - // No emailAction exists yet - create one then insert recipients const [emailActionRow] = await db .insert(alertEmailActions) .values({ alertRuleId, enabled: true })