Many to one on sites and health checks

This commit is contained in:
Owen
2026-04-15 14:58:33 -07:00
parent f379986a59
commit bf64e226d3
6 changed files with 326 additions and 86 deletions

View File

@@ -480,11 +480,6 @@ export const alertRules = pgTable("alertRules", {
>() >()
.notNull(), .notNull(),
// Nullable depending on eventType // 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), enabled: boolean("enabled").notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300), cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable
@@ -492,6 +487,26 @@ export const alertRules = pgTable("alertRules", {
updatedAt: bigint("updatedAt", { 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 // Separating channels by type avoids the mixed-shape problem entirely
export const alertEmailActions = pgTable("alertEmailActions", { export const alertEmailActions = pgTable("alertEmailActions", {
emailActionId: serial("emailActionId").primaryKey(), emailActionId: serial("emailActionId").primaryKey(),
@@ -499,17 +514,23 @@ export const alertEmailActions = pgTable("alertEmailActions", {
.notNull() .notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }), .references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
}); });
export const alertEmailRecipients = pgTable("alertEmailRecipients", { export const alertEmailRecipients = pgTable("alertEmailRecipients", {
recipientId: serial("recipientId").primaryKey(), recipientId: serial("recipientId").primaryKey(),
emailActionId: integer("emailActionId") emailActionId: integer("emailActionId")
.notNull() .notNull()
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), .references(() => alertEmailActions.emailActionId, {
onDelete: "cascade"
}),
// At least one of these should be set - enforced at app level // At least one of these should be set - enforced at app level
userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), userId: varchar("userId").references(() => users.userId, {
roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }), onDelete: "cascade"
}),
roleId: varchar("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
email: varchar("email", { length: 255 }) // external emails not tied to a user email: varchar("email", { length: 255 }) // external emails not tied to a user
}); });

View File

@@ -471,11 +471,6 @@ export const alertRules = sqliteTable("alertRules", {
| "health_check_not_healthy" | "health_check_not_healthy"
>() >()
.notNull(), .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), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300), cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
lastTriggeredAt: integer("lastTriggeredAt"), lastTriggeredAt: integer("lastTriggeredAt"),
@@ -483,6 +478,26 @@ export const alertRules = sqliteTable("alertRules", {
updatedAt: integer("updatedAt").notNull() 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", { export const alertEmailActions = sqliteTable("alertEmailActions", {
emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }),
alertRuleId: integer("alertRuleId") alertRuleId: integer("alertRuleId")

View File

@@ -16,6 +16,8 @@ import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { import {
alertRules, alertRules,
alertSites,
alertHealthChecks,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions alertWebhookActions
@@ -27,6 +29,12 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; 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({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
}); });
@@ -37,7 +45,8 @@ const webhookActionSchema = z.strictObject({
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
}); });
const bodySchema = z.strictObject({ const bodySchema = z
.strictObject({
name: z.string().nonempty(), name: z.string().nonempty(),
eventType: z.enum([ eventType: z.enum([
"site_online", "site_online",
@@ -45,14 +54,63 @@ const bodySchema = z.strictObject({
"health_check_healthy", "health_check_healthy",
"health_check_not_healthy" "health_check_not_healthy"
]), ]),
siteId: z.number().int().optional(),
healthCheckId: z.number().int().optional(),
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
cooldownSeconds: z.number().int().nonnegative().optional().default(300), 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([]), userIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.string().nonempty()).optional().default([]), roleIds: z.array(z.string().nonempty()).optional().default([]),
emails: z.array(z.string().email()).optional().default([]), emails: z.array(z.string().email()).optional().default([]),
// Webhook actions
webhookActions: z.array(webhookActionSchema).optional().default([]) 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 = { export type CreateAlertRuleResponse = {
@@ -108,10 +166,10 @@ export async function createAlertRule(
const { const {
name, name,
eventType, eventType,
siteId,
healthCheckId,
enabled, enabled,
cooldownSeconds, cooldownSeconds,
siteIds,
healthCheckIds,
userIds, userIds,
roleIds, roleIds,
emails, emails,
@@ -126,8 +184,6 @@ export async function createAlertRule(
orgId, orgId,
name, name,
eventType, eventType,
siteId: siteId ?? null,
healthCheckId: healthCheckId ?? null,
enabled, enabled,
cooldownSeconds, cooldownSeconds,
createdAt: now, createdAt: now,
@@ -135,6 +191,26 @@ export async function createAlertRule(
}) })
.returning(); .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 // Create the email action pivot row and recipients if any recipients
// were supplied (userIds, roleIds, or raw emails). // were supplied (userIds, roleIds, or raw emails).
const hasRecipients = const hasRecipients =
@@ -150,19 +226,19 @@ export async function createAlertRule(
...userIds.map((userId) => ({ ...userIds.map((userId) => ({
emailActionId: emailActionRow.emailActionId, emailActionId: emailActionRow.emailActionId,
userId, userId,
roleId: null, roleId: null as string | null,
email: null email: null as string | null
})), })),
...roleIds.map((roleId) => ({ ...roleIds.map((roleId) => ({
emailActionId: emailActionRow.emailActionId, emailActionId: emailActionRow.emailActionId,
userId: null, userId: null as string | null,
roleId, roleId,
email: null email: null as string | null
})), })),
...emails.map((email) => ({ ...emails.map((email) => ({
emailActionId: emailActionRow.emailActionId, emailActionId: emailActionRow.emailActionId,
userId: null, userId: null as string | null,
roleId: null, roleId: null as string | null,
email email
})) }))
]; ];

View File

@@ -16,6 +16,8 @@ import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { import {
alertRules, alertRules,
alertSites,
alertHealthChecks,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions alertWebhookActions
@@ -44,13 +46,13 @@ export type GetAlertRuleResponse = {
| "site_offline" | "site_offline"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy"; | "health_check_not_healthy";
siteId: number | null;
healthCheckId: number | null;
enabled: boolean; enabled: boolean;
cooldownSeconds: number; cooldownSeconds: number;
lastTriggeredAt: number | null; lastTriggeredAt: number | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
recipients: { recipients: {
recipientId: number; recipientId: number;
userId: string | null; 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 // Resolve the single email action row for this rule, then collect all
// recipients into a flat list. The emailAction pivot row is an internal // recipients into a flat list. The emailAction pivot row is an internal
// implementation detail and is not surfaced to callers. // implementation detail and is not surfaced to callers.
@@ -138,6 +152,7 @@ export async function getAlertRule(
})); }));
} }
// Fetch webhook actions
const webhooks = await db const webhooks = await db
.select() .select()
.from(alertWebhookActions) .from(alertWebhookActions)
@@ -149,13 +164,13 @@ export async function getAlertRule(
orgId: rule.orgId, orgId: rule.orgId,
name: rule.name, name: rule.name,
eventType: rule.eventType, eventType: rule.eventType,
siteId: rule.siteId ?? null,
healthCheckId: rule.healthCheckId ?? null,
enabled: rule.enabled, enabled: rule.enabled,
cooldownSeconds: rule.cooldownSeconds, cooldownSeconds: rule.cooldownSeconds,
lastTriggeredAt: rule.lastTriggeredAt ?? null, lastTriggeredAt: rule.lastTriggeredAt ?? null,
createdAt: rule.createdAt, createdAt: rule.createdAt,
updatedAt: rule.updatedAt, updatedAt: rule.updatedAt,
siteIds: siteRows.map((r) => r.siteId),
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
recipients, recipients,
webhookActions: webhooks.map((w) => ({ webhookActions: webhooks.map((w) => ({
webhookActionId: w.webhookActionId, webhookActionId: w.webhookActionId,

View File

@@ -1,7 +1,7 @@
/* /*
* This file is part of a proprietary work. * This file is part of a proprietary work.
* *
* Copyright (c) 2025-2026 Fossorial, Inc. * Copyright (c) 2025 Fossorial, Inc.
* All rights reserved. * All rights reserved.
* *
* This file is licensed under the Fossorial Commercial License. * This file is licensed under the Fossorial Commercial License.
@@ -14,14 +14,14 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { alertRules } from "@server/db"; import { alertRules, alertSites, alertHealthChecks } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { eq, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -48,13 +48,13 @@ export type ListAlertRulesResponse = {
orgId: string; orgId: string;
name: string; name: string;
eventType: string; eventType: string;
siteId: number | null;
healthCheckId: number | null;
enabled: boolean; enabled: boolean;
cooldownSeconds: number; cooldownSeconds: number;
lastTriggeredAt: number | null; lastTriggeredAt: number | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
}[]; }[];
pagination: { pagination: {
total: number; total: number;
@@ -116,9 +116,59 @@ export async function listAlertRules(
.from(alertRules) .from(alertRules)
.where(eq(alertRules.orgId, orgId)); .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<number, number[]>();
for (const row of siteRows) {
const existing = sitesByRule.get(row.alertRuleId) ?? [];
existing.push(row.siteId);
sitesByRule.set(row.alertRuleId, existing);
}
const healthChecksByRule = new Map<number, number[]>();
for (const row of healthCheckRows) {
const existing = healthChecksByRule.get(row.alertRuleId) ?? [];
existing.push(row.healthCheckId);
healthChecksByRule.set(row.alertRuleId, existing);
}
return response<ListAlertRulesResponse>(res, { return response<ListAlertRulesResponse>(res, {
data: { 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: { pagination: {
total: count, total: count,
limit, limit,

View File

@@ -16,6 +16,8 @@ import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { import {
alertRules, alertRules,
alertSites,
alertHealthChecks,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions alertWebhookActions
@@ -28,6 +30,12 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm"; 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 const paramsSchema = z
.object({ .object({
orgId: z.string().nonempty(), orgId: z.string().nonempty(),
@@ -41,7 +49,8 @@ const webhookActionSchema = z.strictObject({
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
}); });
const bodySchema = z.strictObject({ const bodySchema = z
.strictObject({
// Alert rule fields - all optional for partial updates // Alert rule fields - all optional for partial updates
name: z.string().nonempty().optional(), name: z.string().nonempty().optional(),
eventType: z eventType: z
@@ -52,16 +61,43 @@ const bodySchema = z.strictObject({
"health_check_not_healthy" "health_check_not_healthy"
]) ])
.optional(), .optional(),
siteId: z.number().int().nullable().optional(),
healthCheckId: z.number().int().nullable().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
cooldownSeconds: z.number().int().nonnegative().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 // Recipient arrays - if any are provided the full recipient set is replaced
userIds: z.array(z.string().nonempty()).optional(), userIds: z.array(z.string().nonempty()).optional(),
roleIds: z.array(z.string().nonempty()).optional(), roleIds: z.array(z.string().nonempty()).optional(),
emails: z.array(z.string().email()).optional(), emails: z.array(z.string().email()).optional(),
// Webhook actions - if provided the full webhook set is replaced // Webhook actions - if provided the full webhook set is replaced
webhookActions: z.array(webhookActionSchema).optional() 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 = { export type UpdateAlertRuleResponse = {
@@ -133,10 +169,10 @@ export async function updateAlertRule(
const { const {
name, name,
eventType, eventType,
siteId,
healthCheckId,
enabled, enabled,
cooldownSeconds, cooldownSeconds,
siteIds,
healthCheckIds,
userIds, userIds,
roleIds, roleIds,
emails, emails,
@@ -150,10 +186,9 @@ export async function updateAlertRule(
if (name !== undefined) updateData.name = name; if (name !== undefined) updateData.name = name;
if (eventType !== undefined) updateData.eventType = eventType; 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 (enabled !== undefined) updateData.enabled = enabled;
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; if (cooldownSeconds !== undefined)
updateData.cooldownSeconds = cooldownSeconds;
await db await db
.update(alertRules) .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 --- // --- Full-replace recipients if any recipient array was provided ---
const recipientsProvided = const recipientsProvided =
userIds !== undefined || userIds !== undefined ||
@@ -172,7 +239,6 @@ export async function updateAlertRule(
emails !== undefined; emails !== undefined;
if (recipientsProvided) { if (recipientsProvided) {
// Build the flat list of recipient rows to insert
const newRecipients = [ const newRecipients = [
...(userIds ?? []).map((userId) => ({ ...(userIds ?? []).map((userId) => ({
userId, userId,
@@ -191,14 +257,12 @@ export async function updateAlertRule(
})) }))
]; ];
// Find or create the single emailAction row for this rule
const [existingEmailAction] = await db const [existingEmailAction] = await db
.select() .select()
.from(alertEmailActions) .from(alertEmailActions)
.where(eq(alertEmailActions.alertRuleId, alertRuleId)); .where(eq(alertEmailActions.alertRuleId, alertRuleId));
if (existingEmailAction) { if (existingEmailAction) {
// Delete all current recipients then re-insert
await db await db
.delete(alertEmailRecipients) .delete(alertEmailRecipients)
.where( .where(
@@ -217,7 +281,6 @@ export async function updateAlertRule(
); );
} }
} else if (newRecipients.length > 0) { } else if (newRecipients.length > 0) {
// No emailAction exists yet - create one then insert recipients
const [emailActionRow] = await db const [emailActionRow] = await db
.insert(alertEmailActions) .insert(alertEmailActions)
.values({ alertRuleId, enabled: true }) .values({ alertRuleId, enabled: true })