diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 9007013b1..649993e46 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -528,7 +528,7 @@ export const alertEmailRecipients = pgTable("alertEmailRecipients", { userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: varchar("roleId").references(() => roles.roleId, { + roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), email: varchar("email", { length: 255 }) // external emails not tied to a user diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 318a094dd..435a50e32 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -513,7 +513,7 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { .notNull() .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), email: text("email") }); diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 40408d898..25ac64afb 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -13,7 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, roles } from "@server/db"; import { alertRules, alertSites, @@ -30,6 +30,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import { and, eq } from "drizzle-orm"; const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; const HC_EVENT_TYPES = [ @@ -66,7 +67,7 @@ const bodySchema = z .default([]), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), - roleIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number()).optional().default([]), emails: z.array(z.string().email()).optional().default([]), // Webhook actions webhookActions: z.array(webhookActionSchema).optional().default([]) @@ -82,8 +83,7 @@ const bodySchema = z if (isSiteEvent && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "At least one siteId is required for site event types", + message: "At least one siteId is required for site event types", path: ["siteIds"] }); } @@ -108,8 +108,7 @@ const bodySchema = z if (isHcEvent && val.siteIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "siteIds must not be set for health check event types", + message: "siteIds must not be set for health check event types", path: ["siteIds"] }); } @@ -216,7 +215,9 @@ export async function createAlertRule( // 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; + userIds.length > 0 || + roleIds.length > 0 || + emails.length > 0; if (hasRecipients) { const [emailActionRow] = await db @@ -228,7 +229,7 @@ export async function createAlertRule( ...userIds.map((userId) => ({ emailActionId: emailActionRow.emailActionId, userId, - roleId: null as string | null, + roleId: null as number | null, email: null as string | null })), ...roleIds.map((roleId) => ({ @@ -240,7 +241,7 @@ export async function createAlertRule( ...emails.map((email) => ({ emailActionId: emailActionRow.emailActionId, userId: null as string | null, - roleId: null as string | null, + roleId: null as number | null, email })) ]; @@ -254,7 +255,10 @@ export async function createAlertRule( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config != null ? encrypt(wa.config, serverSecret) : null, + config: + wa.config != null + ? encrypt(wa.config, serverSecret) + : null, enabled: wa.enabled })) ); @@ -275,4 +279,4 @@ export async function createAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index add031dc4..398156258 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -70,7 +70,7 @@ const bodySchema = z 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(), + roleIds: z.array(z.number()).optional(), emails: z.array(z.string().email()).optional(), // Webhook actions - if provided the full webhook set is replaced webhookActions: z.array(webhookActionSchema).optional() @@ -244,7 +244,7 @@ export async function updateAlertRule( const newRecipients = [ ...(userIds ?? []).map((userId) => ({ userId, - roleId: null as string | null, + roleId: null as number | null, email: null as string | null })), ...(roleIds ?? []).map((roleId) => ({ @@ -254,7 +254,7 @@ export async function updateAlertRule( })), ...(emails ?? []).map((email) => ({ userId: null as string | null, - roleId: null as string | null, + roleId: null as number | null, email })) ]; @@ -331,4 +331,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 34afceaab..50c612bbf 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -49,7 +49,6 @@ export default function EditAlertRulePage() { }); setFormValues(null); }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [orgId, alertRuleId]); useEffect(() => { diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 2756ca165..b38639f37 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -66,7 +66,7 @@ export type AlertRuleApiPayload = { siteIds: number[]; healthCheckIds: number[]; userIds: string[]; - roleIds: string[]; + roleIds: number[]; emails: string[]; webhookActions: { webhookUrl: string; @@ -91,7 +91,7 @@ export type AlertRuleApiResponse = { recipients: { recipientId: number; userId: string | null; - roleId: string | null; + roleId: number | null; email: string | null; }[]; webhookActions: { @@ -297,7 +297,7 @@ export function apiResponseToFormValues( .map((r) => ({ id: r.userId!, text: r.userId! })); const roleTags = rule.recipients .filter((r) => r.roleId != null) - .map((r) => ({ id: r.roleId!, text: r.roleId! })); + .map((r) => ({ id: String(r.roleId!), text: String(r.roleId!) })); const emailTags = rule.recipients .filter((r) => r.email != null) .map((r) => ({ id: r.email!, text: r.email! })); @@ -358,7 +358,7 @@ export function formValuesToApiPayload( // Collect all notify-type actions and merge their recipient lists const allUserIds: string[] = []; - const allRoleIds: string[] = []; + const allRoleIds: number[] = []; const allEmails: string[] = []; const webhookActions: AlertRuleApiPayload["webhookActions"] = []; @@ -366,7 +366,7 @@ export function formValuesToApiPayload( for (const action of values.actions) { if (action.type === "notify") { allUserIds.push(...action.userTags.map((t) => t.id)); - allRoleIds.push(...action.roleTags.map((t) => t.id)); + allRoleIds.push(...action.roleTags.map((t) => Number(t.id))); allEmails.push( ...action.emailTags .map((t) => t.text.trim()) @@ -391,7 +391,7 @@ export function formValuesToApiPayload( // Deduplicate const uniqueUserIds = [...new Set(allUserIds)]; - const uniqueRoleIds = [...new Set(allRoleIds)]; + const uniqueRoleIds: number[] = [...new Set(allRoleIds)]; const uniqueEmails = [...new Set(allEmails)]; return { diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts deleted file mode 100644 index 2471219b0..000000000 --- a/src/lib/alertRulesLocalStorage.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { z } from "zod"; - -const STORAGE_PREFIX = "pangolin:alert-rules:"; - -export const webhookHeaderEntrySchema = z.object({ - key: z.string(), - value: z.string() -}); - -export const alertActionSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userIds: z.array(z.string()), - roleIds: z.array(z.number()), - emails: z.array(z.string()) - }), - z.object({ - type: z.literal("webhook"), - url: z.string().url(), - method: z.string().min(1), - headers: z.array(webhookHeaderEntrySchema), - secret: z.string().optional() - }) -]); - -export const alertSourceSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("site"), - siteIds: z.array(z.number()) - }), - z.object({ - type: z.literal("health_check"), - targetIds: z.array(z.number()) - }) -]); - -export const alertTriggerSchema = z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_unhealthy" -]); - -export const alertRuleSchema = z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(255), - enabled: z.boolean(), - createdAt: z.string(), - updatedAt: z.string(), - source: alertSourceSchema, - trigger: alertTriggerSchema, - actions: z.array(alertActionSchema).min(1) -}); - -export type AlertRule = z.infer; -export type AlertAction = z.infer; -export type AlertTrigger = z.infer; - -function storageKey(orgId: string) { - return `${STORAGE_PREFIX}${orgId}`; -} - -export function getRule(orgId: string, ruleId: string): AlertRule | undefined { - return loadRules(orgId).find((r) => r.id === ruleId); -} - -export function loadRules(orgId: string): AlertRule[] { - if (typeof window === "undefined") { - return []; - } - try { - const raw = localStorage.getItem(storageKey(orgId)); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - return []; - } - const out: AlertRule[] = []; - for (const item of parsed) { - const r = alertRuleSchema.safeParse(item); - if (r.success) { - out.push(r.data); - } - } - return out; - } catch { - return []; - } -} - -export function saveRules(orgId: string, rules: AlertRule[]) { - if (typeof window === "undefined") { - return; - } - localStorage.setItem(storageKey(orgId), JSON.stringify(rules)); -} - -export function upsertRule(orgId: string, rule: AlertRule) { - const rules = loadRules(orgId); - const i = rules.findIndex((r) => r.id === rule.id); - if (i >= 0) { - rules[i] = rule; - } else { - rules.push(rule); - } - saveRules(orgId, rules); -} - -export function deleteRule(orgId: string, ruleId: string) { - const rules = loadRules(orgId).filter((r) => r.id !== ruleId); - saveRules(orgId, rules); -} - -export function newRuleId() { - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); - } - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -export function isoNow() { - return new Date().toISOString(); -}