From 935b97d1b9cf8325ab0277fc5d70d7040fc9f86d Mon Sep 17 00:00:00 2001 From: mendarb Date: Fri, 20 Mar 2026 14:30:10 +0100 Subject: [PATCH 1/2] feat: add alarms system with database schema, API endpoints, and dashboard UI Implements the complete alarms system (#267): - Database: alarms table with trigger types, notification channels, and proper indexes - API: CRUD endpoints (list, get, create, update, delete, test) via ORPC - Dashboard: notifications settings page with alarm management UI - Tests: validation schema tests for create/update operations - Integrates with @databuddy/notifications package for Slack, Discord, and webhook delivery Closes #267 Co-Authored-By: Claude Opus 4.6 --- .../_components/alarm-dialog.tsx | 356 ++++++++++++++++ .../(main)/settings/notifications/page.tsx | 356 +++++++++++++++- packages/db/src/drizzle/relations.ts | 16 + packages/db/src/drizzle/schema.ts | 68 ++++ packages/rpc/src/root.ts | 2 + packages/rpc/src/routers/alarms.test.ts | 226 +++++++++++ packages/rpc/src/routers/alarms.ts | 381 ++++++++++++++++++ 7 files changed, 1388 insertions(+), 17 deletions(-) create mode 100644 apps/dashboard/app/(main)/settings/notifications/_components/alarm-dialog.tsx create mode 100644 packages/rpc/src/routers/alarms.test.ts create mode 100644 packages/rpc/src/routers/alarms.ts diff --git a/apps/dashboard/app/(main)/settings/notifications/_components/alarm-dialog.tsx b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-dialog.tsx new file mode 100644 index 000000000..80c2b5f6d --- /dev/null +++ b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-dialog.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { + BellIcon, + SpinnerGapIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { orpc } from "@/lib/orpc"; +import { cn } from "@/lib/utils"; +import { activeOrganizationAtom } from "@/stores/jotai/organizationsAtoms"; + +interface Alarm { + id: string; + name: string; + description: string | null; + enabled: boolean; + notificationChannels: string[]; + triggerType: string; + slackWebhookUrl: string | null; + discordWebhookUrl: string | null; + emailAddresses: string[] | null; + webhookUrl: string | null; + webhookHeaders: Record | null; + triggerConditions: Record | null; + websiteId: string | null; + organizationId: string | null; +} + +interface AlarmDialogProps { + alarm: Alarm | null; + isOpen: boolean; + onCloseAction: () => void; +} + +const TRIGGER_TYPES = [ + { value: "uptime", label: "Uptime" }, + { value: "traffic_spike", label: "Traffic Spike" }, + { value: "error_rate", label: "Error Rate" }, + { value: "goal", label: "Goal" }, + { value: "custom", label: "Custom" }, +] as const; + +const CHANNELS = [ + { value: "slack", label: "Slack" }, + { value: "discord", label: "Discord" }, + { value: "email", label: "Email" }, + { value: "webhook", label: "Webhook" }, +] as const; + +type TriggerType = (typeof TRIGGER_TYPES)[number]["value"]; +type Channel = (typeof CHANNELS)[number]["value"]; + +export function AlarmDialog({ alarm, isOpen, onCloseAction }: AlarmDialogProps) { + const queryClient = useQueryClient(); + const activeOrganization = useAtomValue(activeOrganizationAtom); + const isEditing = Boolean(alarm); + + const [name, setName] = useState(alarm?.name ?? ""); + const [description, setDescription] = useState(alarm?.description ?? ""); + const [enabled, setEnabled] = useState(alarm?.enabled ?? true); + const [triggerType, setTriggerType] = useState( + (alarm?.triggerType as TriggerType) ?? "uptime" + ); + const [channels, setChannels] = useState( + (alarm?.notificationChannels as Channel[]) ?? [] + ); + const [slackUrl, setSlackUrl] = useState(alarm?.slackWebhookUrl ?? ""); + const [discordUrl, setDiscordUrl] = useState(alarm?.discordWebhookUrl ?? ""); + const [emailAddresses, setEmailAddresses] = useState( + alarm?.emailAddresses?.join(", ") ?? "" + ); + const [webhookUrl, setWebhookUrl] = useState(alarm?.webhookUrl ?? ""); + + const createMutation = useMutation({ + ...orpc.alarms.create.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.alarms.list.key({ input: {} }), + }); + toast.success("Alarm created"); + onCloseAction(); + }, + onError: () => { + toast.error("Failed to create alarm"); + }, + }); + + const updateMutation = useMutation({ + ...orpc.alarms.update.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.alarms.list.key({ input: {} }), + }); + toast.success("Alarm updated"); + onCloseAction(); + }, + onError: () => { + toast.error("Failed to update alarm"); + }, + }); + + const toggleChannel = (channel: Channel) => { + setChannels((prev) => + prev.includes(channel) + ? prev.filter((c) => c !== channel) + : [...prev, channel] + ); + }; + + const handleSubmit = () => { + if (!name.trim()) { + toast.error("Name is required"); + return; + } + + if (channels.length === 0) { + toast.error("Select at least one notification channel"); + return; + } + + if (!activeOrganization?.id && !isEditing) { + toast.error("No active workspace found"); + return; + } + + const emails = emailAddresses + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + + if (isEditing && alarm) { + updateMutation.mutate({ + id: alarm.id, + name: name.trim(), + description: description.trim() || undefined, + enabled, + notificationChannels: channels, + triggerType, + slackWebhookUrl: slackUrl.trim() || null, + discordWebhookUrl: discordUrl.trim() || null, + emailAddresses: emails.length > 0 ? emails : null, + webhookUrl: webhookUrl.trim() || null, + }); + } else { + createMutation.mutate({ + organizationId: activeOrganization?.id ?? "", + name: name.trim(), + description: description.trim() || undefined, + enabled, + notificationChannels: channels, + triggerType, + slackWebhookUrl: slackUrl.trim() || undefined, + discordWebhookUrl: discordUrl.trim() || undefined, + emailAddresses: emails.length > 0 ? emails : undefined, + webhookUrl: webhookUrl.trim() || undefined, + }); + } + }; + + const isLoading = createMutation.isPending || updateMutation.isPending; + + return ( + !open && onCloseAction()} open={isOpen}> + + +
+
+ +
+
+ + {isEditing ? "Edit Alarm" : "Create Alarm"} + + + {isEditing + ? `Editing ${alarm?.name}` + : "Set up a notification alarm"} + +
+
+
+ +
+
+ + setName(e.target.value)} + placeholder="Site Down Alert" + value={name} + /> +
+ +
+ +