diff --git a/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx new file mode 100644 index 000000000..80997ffc7 --- /dev/null +++ b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx @@ -0,0 +1,646 @@ +"use client"; + +import type { AlarmForm } from "@databuddy/shared/alarms"; +import { alarmFormSchema } from "@databuddy/shared/alarms"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + BellIcon, + EnvelopeIcon, + PlusIcon, + SpinnerGapIcon, + TrashIcon, + WebhookLogoIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Sheet, + SheetBody, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { orpc } from "@/lib/orpc"; +import { cn } from "@/lib/utils"; +import type { Alarm } from "./types"; + +interface AlarmSheetProps { + isOpen: boolean; + onCloseAction: () => void; + organizationId: string; + alarm?: Alarm | null; +} + +const TRIGGER_TYPES = [ + { value: "uptime", label: "Uptime", description: "Site goes down" }, + { + value: "traffic_spike", + label: "Traffic Spike", + description: "Unusual traffic", + }, + { + value: "error_rate", + label: "Error Rate", + description: "Errors exceed threshold", + }, + { value: "goal", label: "Goal", description: "Goal completed" }, + { value: "custom", label: "Custom", description: "Custom condition" }, +] as const; + +const CHANNELS = [ + { value: "slack", label: "Slack" }, + { value: "discord", label: "Discord" }, + { value: "email", label: "Email" }, + { value: "webhook", label: "Webhook" }, +] as const; + +export function AlarmSheet({ + isOpen, + onCloseAction, + organizationId, + alarm, +}: AlarmSheetProps) { + const queryClient = useQueryClient(); + const isEditing = Boolean(alarm); + const [emailInput, setEmailInput] = useState(""); + const [headerKey, setHeaderKey] = useState(""); + const [headerValue, setHeaderValue] = useState(""); + + const form = useForm({ + resolver: zodResolver(alarmFormSchema), + defaultValues: { + name: "", + description: "", + enabled: true, + notificationChannels: [], + slackWebhookUrl: "", + discordWebhookUrl: "", + emailAddresses: [], + webhookUrl: "", + webhookHeaders: {}, + triggerType: "uptime", + triggerConditions: {}, + }, + }); + + const createMutation = useMutation({ + ...orpc.alarms.create.mutationOptions(), + }); + const updateMutation = useMutation({ + ...orpc.alarms.update.mutationOptions(), + }); + + const resetForm = useCallback(() => { + if (alarm && isEditing) { + form.reset({ + name: alarm.name, + description: alarm.description || "", + enabled: alarm.enabled, + notificationChannels: + (alarm.notificationChannels as AlarmForm["notificationChannels"]) || + [], + slackWebhookUrl: alarm.slackWebhookUrl || "", + discordWebhookUrl: alarm.discordWebhookUrl || "", + emailAddresses: (alarm.emailAddresses as string[]) || [], + webhookUrl: alarm.webhookUrl || "", + webhookHeaders: (alarm.webhookHeaders as Record) || {}, + triggerType: + (alarm.triggerType as AlarmForm["triggerType"]) || "uptime", + triggerConditions: + (alarm.triggerConditions as Record) || {}, + }); + } else { + form.reset({ + name: "", + description: "", + enabled: true, + notificationChannels: [], + slackWebhookUrl: "", + discordWebhookUrl: "", + emailAddresses: [], + webhookUrl: "", + webhookHeaders: {}, + triggerType: "uptime", + triggerConditions: {}, + }); + } + setEmailInput(""); + setHeaderKey(""); + setHeaderValue(""); + }, [alarm, isEditing, form]); + + useEffect(() => { + if (isOpen) { + resetForm(); + } + }, [alarm?.id, isOpen, resetForm]); + + const handleOpenChange = (open: boolean) => { + if (!open) { + onCloseAction(); + } + }; + + const watchedChannels = form.watch("notificationChannels") || []; + const watchedEmails = form.watch("emailAddresses") || []; + const watchedHeaders = form.watch("webhookHeaders") || {}; + + const toggleChannel = ( + channel: AlarmForm["notificationChannels"][number] + ) => { + const current = form.getValues("notificationChannels") || []; + if (current.includes(channel)) { + form.setValue( + "notificationChannels", + current.filter((c) => c !== channel) + ); + } else { + form.setValue("notificationChannels", [...current, channel]); + } + }; + + const addEmail = () => { + const trimmed = emailInput.trim(); + if (!trimmed) { + return; + } + const current = form.getValues("emailAddresses") || []; + if (!current.includes(trimmed)) { + form.setValue("emailAddresses", [...current, trimmed]); + } + setEmailInput(""); + }; + + const removeEmail = (email: string) => { + const current = form.getValues("emailAddresses") || []; + form.setValue( + "emailAddresses", + current.filter((e) => e !== email) + ); + }; + + const addHeader = () => { + const key = headerKey.trim(); + const value = headerValue.trim(); + if (!key) { + return; + } + const current = form.getValues("webhookHeaders") || {}; + form.setValue("webhookHeaders", { ...current, [key]: value }); + setHeaderKey(""); + setHeaderValue(""); + }; + + const removeHeader = (key: string) => { + const current = { ...(form.getValues("webhookHeaders") || {}) }; + delete current[key]; + form.setValue("webhookHeaders", current); + }; + + const onSubmit = async (formData: AlarmForm) => { + try { + if (isEditing && alarm) { + await updateMutation.mutateAsync({ + id: alarm.id, + name: formData.name, + description: formData.description || null, + enabled: formData.enabled, + notificationChannels: formData.notificationChannels, + slackWebhookUrl: formData.slackWebhookUrl || null, + discordWebhookUrl: formData.discordWebhookUrl || null, + emailAddresses: formData.emailAddresses, + webhookUrl: formData.webhookUrl || null, + webhookHeaders: formData.webhookHeaders, + triggerType: formData.triggerType, + triggerConditions: formData.triggerConditions, + }); + } else { + await createMutation.mutateAsync({ + organizationId, + ...formData, + }); + } + + toast.success(`Alarm ${isEditing ? "updated" : "created"} successfully`); + + queryClient.invalidateQueries({ + queryKey: orpc.alarms.list.key({ + input: { organizationId }, + }), + }); + + onCloseAction(); + } catch { + toast.error(`Failed to ${isEditing ? "update" : "create"} alarm`); + } + }; + + const isLoading = createMutation.isPending || updateMutation.isPending; + + return ( + + + +
+
+ +
+
+ + {isEditing ? "Edit Alarm" : "Create Alarm"} + + + {isEditing + ? `Editing ${alarm?.name}` + : "Set up a new notification alarm"} + +
+
+
+ +
+ + + {/* Basic Info */} +
+ ( + + + Name * + + + + + + + )} + /> + + ( + + + Description (optional) + + +