diff --git a/prisma/migrations/20260428000000_drop_alert_webhook/migration.sql b/prisma/migrations/20260428000000_drop_alert_webhook/migration.sql new file mode 100644 index 00000000..d2954db6 --- /dev/null +++ b/prisma/migrations/20260428000000_drop_alert_webhook/migration.sql @@ -0,0 +1,17 @@ +-- Drop the legacy AlertWebhook model in favor of NotificationChannel(type=webhook). +-- +-- The NotificationChannel infrastructure (drivers, DeliveryAttempt tracking, +-- AlertRuleChannel routing) fully subsumes AlertWebhook's functionality and +-- adds delivery tracking, retries, and per-rule routing that AlertWebhook +-- never had. +-- +-- Existing AlertWebhook rows MUST be migrated to NotificationChannel(type=webhook) +-- before this migration is applied (operator action — no automated copy because +-- hmacSecret is plaintext and config encryption strategy is decided per-deploy). + +-- Drop the AlertWebhook table (CASCADE drops the FK from Environment too). +DROP TABLE IF EXISTS "AlertWebhook" CASCADE; + +-- Drop the dangling webhookId column on DeliveryAttempt (only legacy_webhook +-- rows used it; all DeliveryAttempts now route via channelId). +ALTER TABLE "DeliveryAttempt" DROP COLUMN IF EXISTS "webhookId"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 16077fa6..6ccca7f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -150,7 +150,6 @@ model Environment { gitWebhookSecret String? // HMAC secret for validating incoming git webhooks requireDeployApproval Boolean @default(false) alertRules AlertRule[] - alertWebhooks AlertWebhook[] notificationChannels NotificationChannel[] serviceAccounts ServiceAccount[] deployRequests DeployRequest[] @@ -1012,20 +1011,6 @@ model AlertRule { @@index([teamId]) } -model AlertWebhook { - id String @id @default(cuid()) - environmentId String - environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) - url String - headers Json? - hmacSecret String? - enabled Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([environmentId]) -} - model WebhookEndpoint { id String @id @default(cuid()) teamId String @@ -1106,14 +1091,13 @@ model DeliveryAttempt { id String @id @default(cuid()) alertEventId String alertEvent AlertEvent @relation(fields: [alertEventId], references: [id], onDelete: Cascade) - channelType String // 'webhook' | 'slack' | 'email' | 'pagerduty' | 'legacy_webhook' + channelType String // 'webhook' | 'slack' | 'email' | 'pagerduty' channelName String status String // 'pending' | 'success' | 'failed' statusCode Int? errorMessage String? attemptNumber Int @default(1) nextRetryAt DateTime? - webhookId String? channelId String? requestedAt DateTime @default(now()) completedAt DateTime? diff --git a/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx b/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx index afe8646c..31243f49 100644 --- a/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx +++ b/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx @@ -20,7 +20,6 @@ const CHANNEL_ICONS: Record = { email: "📧", webhook: "🌐", pagerduty: "🚨", - legacy_webhook: "🌐", }; function channelIcon(type: string): string { diff --git a/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx b/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx index 9a5d8638..250ea7fd 100644 --- a/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx +++ b/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx @@ -72,7 +72,6 @@ export function FailedDeliveriesSection({ environmentId }: FailedDeliveriesSecti email: "📧", webhook: "🌐", pagerduty: "🚨", - legacy_webhook: "🌐", }; return ( diff --git a/src/app/(dashboard)/alerts/_components/webhooks-section.tsx b/src/app/(dashboard)/alerts/_components/webhooks-section.tsx deleted file mode 100644 index c02ce0af..00000000 --- a/src/app/(dashboard)/alerts/_components/webhooks-section.tsx +++ /dev/null @@ -1,458 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import { - useQuery, - useMutation, - useQueryClient, -} from "@tanstack/react-query"; -import { useTRPC } from "@/trpc/client"; -import { toast } from "sonner"; -import { - Plus, - Pencil, - Trash2, - Loader2, - Send, - Webhook, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Textarea } from "@/components/ui/textarea"; -import { QueryError } from "@/components/query-error"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { ConfirmDialog } from "@/components/confirm-dialog"; - -// ─── Legacy Webhooks Section (preserved for backward compatibility) ────────── - -interface WebhookFormState { - url: string; - headers: string; - hmacSecret: string; -} - -const EMPTY_WEBHOOK_FORM: WebhookFormState = { - url: "", - headers: "", - hmacSecret: "", -}; - -export function WebhooksSection({ environmentId }: { environmentId: string }) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const [dialogOpen, setDialogOpen] = useState(false); - const [editingWebhookId, setEditingWebhookId] = useState(null); - const [form, setForm] = useState(EMPTY_WEBHOOK_FORM); - const [deleteTarget, setDeleteTarget] = useState<{ - id: string; - url: string; - } | null>(null); - - const webhooksQuery = useQuery( - trpc.alert.listWebhooks.queryOptions( - { environmentId }, - { enabled: !!environmentId }, - ), - ); - - const invalidateWebhooks = useCallback(() => { - queryClient.invalidateQueries({ - queryKey: trpc.alert.listWebhooks.queryKey({ environmentId }), - }); - }, [queryClient, trpc, environmentId]); - - const createMutation = useMutation( - trpc.alert.createWebhook.mutationOptions({ - onSuccess: () => { - toast.success("Webhook created"); - invalidateWebhooks(); - setDialogOpen(false); - }, - onError: (error) => { - toast.error(error.message || "Failed to create webhook", { duration: 6000 }); - }, - }), - ); - - const updateMutation = useMutation( - trpc.alert.updateWebhook.mutationOptions({ - onSuccess: () => { - toast.success("Webhook updated"); - invalidateWebhooks(); - setDialogOpen(false); - setEditingWebhookId(null); - }, - onError: (error) => { - toast.error(error.message || "Failed to update webhook", { duration: 6000 }); - }, - }), - ); - - const toggleMutation = useMutation( - trpc.alert.updateWebhook.mutationOptions({ - onSuccess: () => { - invalidateWebhooks(); - }, - onError: (error) => { - toast.error(error.message || "Failed to toggle webhook", { duration: 6000 }); - }, - }), - ); - - const deleteMutation = useMutation( - trpc.alert.deleteWebhook.mutationOptions({ - onSuccess: () => { - toast.success("Webhook deleted"); - invalidateWebhooks(); - setDeleteTarget(null); - }, - onError: (error) => { - toast.error(error.message || "Failed to delete webhook", { duration: 6000 }); - }, - }), - ); - - const testMutation = useMutation( - trpc.alert.testWebhook.mutationOptions({ - onSuccess: (result) => { - if (result.success) { - toast.success( - `Webhook test successful (${result.statusCode} ${result.statusText})`, - ); - } else { - toast.error( - `Webhook test failed: ${result.statusCode} ${result.statusText}`, - { duration: 6000 }, - ); - } - }, - onError: (error) => { - toast.error(error.message || "Failed to test webhook", { duration: 6000 }); - }, - }), - ); - - const webhooks = webhooksQuery.data ?? []; - - const openCreate = () => { - setEditingWebhookId(null); - setForm(EMPTY_WEBHOOK_FORM); - setDialogOpen(true); - }; - - const openEdit = (webhook: (typeof webhooks)[0]) => { - setEditingWebhookId(webhook.id); - const headersStr = webhook.headers - ? JSON.stringify(webhook.headers, null, 2) - : ""; - setForm({ - url: webhook.url, - headers: headersStr, - hmacSecret: "", - }); - setDialogOpen(true); - }; - - const parseHeaders = ( - raw: string, - ): Record | undefined => { - if (!raw.trim()) return undefined; - try { - const parsed = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - toast.error("Headers must be a JSON object", { duration: 6000 }); - return undefined; - } - return parsed as Record; - } catch { - toast.error("Invalid JSON in headers field", { duration: 6000 }); - return undefined; - } - }; - - const handleSubmit = () => { - if (!form.url) { - toast.error("URL is required", { duration: 6000 }); - return; - } - - let headers: Record | undefined | null; - if (form.headers.trim()) { - headers = parseHeaders(form.headers); - if (headers === undefined) return; // parse error already shown - } else { - headers = editingWebhookId ? null : undefined; - } - - if (editingWebhookId) { - updateMutation.mutate({ - id: editingWebhookId, - url: form.url, - headers: headers, - hmacSecret: form.hmacSecret || null, - }); - } else { - createMutation.mutate({ - environmentId, - url: form.url, - headers: headers ?? undefined, - hmacSecret: form.hmacSecret || undefined, - }); - } - }; - - const isSaving = createMutation.isPending || updateMutation.isPending; - - // If the query errored, show the error state - if (webhooksQuery.isError) { - return ( -
-
-
- -

Legacy Webhooks

-
-
- webhooksQuery.refetch()} /> -
- ); - } - - // If no legacy webhooks exist, don't show this section - if (!webhooksQuery.isLoading && webhooks.length === 0) { - return null; - } - - return ( -
-
-
- -

Legacy Webhooks

-
- -
- -

- Legacy webhooks are kept for backward compatibility. Consider migrating - to Notification Channels above for a unified experience. -

- - {webhooksQuery.isLoading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : ( -
- - - - URL - Enabled - Actions - - - - {webhooks.map((webhook) => ( - - - {webhook.url} - - - - toggleMutation.mutate({ - id: webhook.id, - enabled: checked, - }) - } - /> - - -
- - - -
-
-
- ))} -
-
-
- )} - - {/* Create / Edit Dialog */} - { - setDialogOpen(open); - if (!open) setEditingWebhookId(null); - }} - > - - - - {editingWebhookId ? "Edit Webhook" : "Add Webhook"} - - - {editingWebhookId - ? "Update the webhook configuration." - : "Configure a new webhook endpoint for alert delivery."} - - - -
-
- - - setForm((f) => ({ ...f, url: e.target.value })) - } - /> -
- -
- -