From 87a554b6ef392f1c790ee3f93d771c05d5aa8897 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 14:33:55 -0700 Subject: [PATCH] Add crud --- server/auth/actions.ts | 7 +- .../routers/alertRule/createAlertRule.ts | 196 ++++++++++++++++++ .../routers/alertRule/deleteAlertRule.ts | 100 +++++++++ .../private/routers/alertRule/getAlertRule.ts | 187 +++++++++++++++++ server/private/routers/alertRule/index.ts | 18 ++ .../routers/alertRule/listAlertRules.ts | 139 +++++++++++++ .../routers/alertRule/updateAlertRule.ts | 160 ++++++++++++++ server/private/routers/external.ts | 43 ++++ 8 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 server/private/routers/alertRule/createAlertRule.ts create mode 100644 server/private/routers/alertRule/deleteAlertRule.ts create mode 100644 server/private/routers/alertRule/getAlertRule.ts create mode 100644 server/private/routers/alertRule/index.ts create mode 100644 server/private/routers/alertRule/listAlertRules.ts create mode 100644 server/private/routers/alertRule/updateAlertRule.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 213dab9d3..40777676c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -144,7 +144,12 @@ export enum ActionsEnum { createEventStreamingDestination = "createEventStreamingDestination", updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", - listEventStreamingDestinations = "listEventStreamingDestinations" + listEventStreamingDestinations = "listEventStreamingDestinations", + createAlertRule = "createAlertRule", + updateAlertRule = "updateAlertRule", + deleteAlertRule = "deleteAlertRule", + listAlertRules = "listAlertRules", + getAlertRule = "getAlertRule" } export async function checkUserActionPermission( diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts new file mode 100644 index 000000000..2c1ef42e3 --- /dev/null +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -0,0 +1,196 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } 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"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +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(), + 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), + emailAction: emailActionSchema.optional(), + webhookActions: z.array(webhookActionSchema).optional() +}); + +export type CreateAlertRuleResponse = { + alertRuleId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/alert-rule", + description: "Create an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { + name, + eventType, + siteId, + healthCheckId, + enabled, + cooldownSeconds, + emailAction, + webhookActions + } = parsedBody.data; + + const now = Date.now(); + + const [rule] = await db + .insert(alertRules) + .values({ + orgId, + name, + eventType, + siteId: siteId ?? null, + healthCheckId: healthCheckId ?? null, + enabled, + cooldownSeconds, + createdAt: now, + updatedAt: now + }) + .returning(); + + if (emailAction) { + const [emailActionRow] = await db + .insert(alertEmailActions) + .values({ + alertRuleId: rule.alertRuleId, + enabled: emailAction.enabled + }) + .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 + })) + ); + } + } + + if (webhookActions && webhookActions.length > 0) { + await db.insert(alertWebhookActions).values( + webhookActions.map((wa) => ({ + alertRuleId: rule.alertRuleId, + webhookUrl: wa.webhookUrl, + config: wa.config ?? null, + enabled: wa.enabled + })) + ); + } + + return response(res, { + data: { + alertRuleId: rule.alertRuleId + }, + success: true, + error: false, + message: "Alert rule created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts new file mode 100644 index 000000000..298ae50bf --- /dev/null +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -0,0 +1,100 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } 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 { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Delete an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const [existing] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + await db + .delete(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Alert rule deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts new file mode 100644 index 000000000..f0c197f05 --- /dev/null +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -0,0 +1,187 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } 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"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +export type GetAlertRuleResponse = { + alertRuleId: number; + orgId: string; + name: string; + eventType: + | "site_online" + | "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; + emailAction: { + emailActionId: number; + enabled: boolean; + lastSentAt: number | null; + recipients: { + recipientId: number; + userId: string | null; + roleId: string | null; + email: string | null; + }[]; + } | null; + webhookActions: { + webhookActionId: number; + webhookUrl: string; + enabled: boolean; + lastSentAt: number | null; + }[]; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Get a specific alert rule for an organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function getAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const [rule] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!rule) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + // Fetch email action and recipients + const [emailAction] = await db + .select() + .from(alertEmailActions) + .where(eq(alertEmailActions.alertRuleId, alertRuleId)); + + let emailActionResult: GetAlertRuleResponse["emailAction"] = null; + if (emailAction) { + const recipients = await db + .select() + .from(alertEmailRecipients) + .where( + eq( + alertEmailRecipients.emailActionId, + emailAction.emailActionId + ) + ); + + 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 + })) + }; + } + + // Fetch webhook actions + const webhooks = await db + .select() + .from(alertWebhookActions) + .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); + + return response(res, { + data: { + alertRuleId: rule.alertRuleId, + 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, + emailAction: emailActionResult, + webhookActions: webhooks.map((w) => ({ + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null + })) + }, + success: true, + error: false, + message: "Alert rule retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts new file mode 100644 index 000000000..b01be8c01 --- /dev/null +++ b/server/private/routers/alertRule/index.ts @@ -0,0 +1,18 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createAlertRule"; +export * from "./updateAlertRule"; +export * from "./deleteAlertRule"; +export * from "./listAlertRules"; +export * from "./getAlertRule"; \ No newline at end of file diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts new file mode 100644 index 000000000..da997a5d8 --- /dev/null +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -0,0 +1,139 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } 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"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export type ListAlertRulesResponse = { + alertRules: { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + siteId: number | null; + healthCheckId: number | null; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/alert-rules", + description: "List all alert rules for a specific organization.", + tags: [OpenAPITags.Org], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); + +export async function listAlertRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await db + .select() + .from(alertRules) + .where(eq(alertRules.orgId, orgId)) + .orderBy(sql`${alertRules.createdAt} DESC`) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(alertRules) + .where(eq(alertRules.orgId, orgId)); + + return response(res, { + data: { + alertRules: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Alert rules retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + 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 new file mode 100644 index 000000000..58ed56071 --- /dev/null +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -0,0 +1,160 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } 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 { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +const bodySchema = z.strictObject({ + 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() +}); + +export type UpdateAlertRuleResponse = { + alertRuleId: number; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Update an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + const { + name, + eventType, + siteId, + healthCheckId, + enabled, + cooldownSeconds + } = parsedBody.data; + + const updateData: Record = { + updatedAt: Date.now() + }; + + 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; + + await db + .update(alertRules) + .set(updateData) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + return response(res, { + data: { + alertRuleId + }, + success: true, + error: false, + message: "Alert rule updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 4410a44c8..590f67a46 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -29,6 +29,7 @@ import * as ssh from "#private/routers/ssh"; import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; +import * as alertRule from "#private/routers/alertRule"; import { verifyOrgAccess, @@ -652,3 +653,45 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), eventStreamingDestination.listEventStreamingDestinations ); + +authenticated.put( + "/org/:orgId/alert-rule", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createAlertRule), + logActionAudit(ActionsEnum.createAlertRule), + alertRule.createAlertRule +); + +authenticated.post( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateAlertRule), + logActionAudit(ActionsEnum.updateAlertRule), + alertRule.updateAlertRule +); + +authenticated.delete( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteAlertRule), + logActionAudit(ActionsEnum.deleteAlertRule), + alertRule.deleteAlertRule +); + +authenticated.get( + "/org/:orgId/alert-rules", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAlertRules), + alertRule.listAlertRules +); + +authenticated.get( + "/org/:orgId/alert-rule/:alertRuleId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getAlertRule), + alertRule.getAlertRule +);