diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index e9834b2dd..20ce52492 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.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. @@ -31,21 +31,6 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const recipientSchema = z - .strictObject({ - userId: z.string().optional(), - roleId: z.string().optional(), - email: z.string().email().optional() - }) - .refine((r) => r.userId || r.roleId || r.email, { - message: "Each recipient must have at least one of userId, roleId, or email" - }); - -const emailActionSchema = z.strictObject({ - enabled: z.boolean().optional().default(true), - recipients: z.array(recipientSchema).min(1) -}); - const webhookActionSchema = z.strictObject({ webhookUrl: z.string().url(), config: z.string().optional(), @@ -64,8 +49,10 @@ const bodySchema = z.strictObject({ healthCheckId: z.number().int().optional(), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), - emailAction: emailActionSchema.optional(), - webhookActions: z.array(webhookActionSchema).optional() + 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([]) }); export type CreateAlertRuleResponse = { @@ -125,7 +112,9 @@ export async function createAlertRule( healthCheckId, enabled, cooldownSeconds, - emailAction, + userIds, + roleIds, + emails, webhookActions } = parsedBody.data; @@ -146,28 +135,42 @@ export async function createAlertRule( }) .returning(); - if (emailAction) { + // Create the email action pivot row and recipients if any recipients + // were supplied (userIds, roleIds, or raw emails). + const hasRecipients = + userIds.length > 0 || roleIds.length > 0 || emails.length > 0; + + if (hasRecipients) { const [emailActionRow] = await db .insert(alertEmailActions) - .values({ - alertRuleId: rule.alertRuleId, - enabled: emailAction.enabled - }) + .values({ alertRuleId: rule.alertRuleId }) .returning(); - if (emailAction.recipients.length > 0) { - await db.insert(alertEmailRecipients).values( - emailAction.recipients.map((r) => ({ - emailActionId: emailActionRow.emailActionId, - userId: r.userId ?? null, - roleId: r.roleId ?? null, - email: r.email ?? null - })) - ); - } + const recipientRows = [ + ...userIds.map((userId) => ({ + emailActionId: emailActionRow.emailActionId, + userId, + roleId: null, + email: null + })), + ...roleIds.map((roleId) => ({ + emailActionId: emailActionRow.emailActionId, + userId: null, + roleId, + email: null + })), + ...emails.map((email) => ({ + emailActionId: emailActionRow.emailActionId, + userId: null, + roleId: null, + email + })) + ]; + + await db.insert(alertEmailRecipients).values(recipientRows); } - if (webhookActions && webhookActions.length > 0) { + if (webhookActions.length > 0) { await db.insert(alertWebhookActions).values( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 5c623cbd1..72c6e1df5 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.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. @@ -51,17 +51,12 @@ export type GetAlertRuleResponse = { lastTriggeredAt: number | null; createdAt: number; updatedAt: number; - emailAction: { - emailActionId: number; - enabled: boolean; - lastSentAt: number | null; - recipients: { - recipientId: number; - userId: string | null; - roleId: string | null; - email: string | null; - }[]; - } | null; + recipients: { + recipientId: number; + userId: string | null; + roleId: string | null; + email: string | null; + }[]; webhookActions: { webhookActionId: number; webhookUrl: string; @@ -115,15 +110,17 @@ export async function getAlertRule( ); } - // Fetch email action and recipients + // 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. const [emailAction] = await db .select() .from(alertEmailActions) .where(eq(alertEmailActions.alertRuleId, alertRuleId)); - let emailActionResult: GetAlertRuleResponse["emailAction"] = null; + let recipients: GetAlertRuleResponse["recipients"] = []; if (emailAction) { - const recipients = await db + const rows = await db .select() .from(alertEmailRecipients) .where( @@ -133,20 +130,14 @@ export async function getAlertRule( ) ); - emailActionResult = { - emailActionId: emailAction.emailActionId, - enabled: emailAction.enabled, - lastSentAt: emailAction.lastSentAt ?? null, - recipients: recipients.map((r) => ({ - recipientId: r.recipientId, - userId: r.userId ?? null, - roleId: r.roleId ?? null, - email: r.email ?? null - })) - }; + recipients = rows.map((r) => ({ + recipientId: r.recipientId, + userId: r.userId ?? null, + roleId: r.roleId ?? null, + email: r.email ?? null + })); } - // Fetch webhook actions const webhooks = await db .select() .from(alertWebhookActions) @@ -165,7 +156,7 @@ export async function getAlertRule( lastTriggeredAt: rule.lastTriggeredAt ?? null, createdAt: rule.createdAt, updatedAt: rule.updatedAt, - emailAction: emailActionResult, + recipients, webhookActions: webhooks.map((w) => ({ webhookActionId: w.webhookActionId, webhookUrl: w.webhookUrl, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 1939c5c6b..05116d3b8 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.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,7 +14,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules } from "@server/db"; +import { + alertRules, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -30,7 +35,14 @@ const paramsSchema = z }) .strict(); +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + 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([ @@ -43,7 +55,13 @@ const bodySchema = z.strictObject({ siteId: z.number().int().nullable().optional(), healthCheckId: z.number().int().nullable().optional(), enabled: z.boolean().optional(), - cooldownSeconds: z.number().int().nonnegative().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() }); export type UpdateAlertRuleResponse = { @@ -118,9 +136,14 @@ export async function updateAlertRule( siteId, healthCheckId, enabled, - cooldownSeconds + cooldownSeconds, + userIds, + roleIds, + emails, + webhookActions } = parsedBody.data; + // --- Update rule fields --- const updateData: Record = { updatedAt: Date.now() }; @@ -142,6 +165,91 @@ export async function updateAlertRule( ) ); + // --- Full-replace recipients if any recipient array was provided --- + const recipientsProvided = + userIds !== undefined || + roleIds !== undefined || + emails !== undefined; + + if (recipientsProvided) { + // Build the flat list of recipient rows to insert + const newRecipients = [ + ...(userIds ?? []).map((userId) => ({ + userId, + roleId: null as string | null, + email: null as string | null + })), + ...(roleIds ?? []).map((roleId) => ({ + userId: null as string | null, + roleId, + email: null as string | null + })), + ...(emails ?? []).map((email) => ({ + userId: null as string | null, + roleId: null as string | null, + email + })) + ]; + + // 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( + eq( + alertEmailRecipients.emailActionId, + existingEmailAction.emailActionId + ) + ); + + if (newRecipients.length > 0) { + await db.insert(alertEmailRecipients).values( + newRecipients.map((r) => ({ + emailActionId: existingEmailAction.emailActionId, + ...r + })) + ); + } + } else if (newRecipients.length > 0) { + // No emailAction exists yet - create one then insert recipients + const [emailActionRow] = await db + .insert(alertEmailActions) + .values({ alertRuleId, enabled: true }) + .returning(); + + await db.insert(alertEmailRecipients).values( + newRecipients.map((r) => ({ + emailActionId: emailActionRow.emailActionId, + ...r + })) + ); + } + } + + // --- Full-replace webhook actions if the array was provided --- + if (webhookActions !== undefined) { + await db + .delete(alertWebhookActions) + .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); + + if (webhookActions.length > 0) { + await db.insert(alertWebhookActions).values( + webhookActions.map((wa) => ({ + alertRuleId, + webhookUrl: wa.webhookUrl, + config: wa.config ?? null, + enabled: wa.enabled + })) + ); + } + } + return response(res, { data: { alertRuleId