feat: add webhooks (#334)

This commit is contained in:
KM Koushik
2026-01-18 20:50:54 +11:00
committed by GitHub
parent f40a311cc9
commit 8676965019
58 changed files with 5334 additions and 245 deletions
@@ -1,5 +1,6 @@
"use client";
import { H1 } from "@usesend/ui";
import { SettingsNavButton } from "./settings-nav-button";
export const dynamic = "force-static";
@@ -11,7 +12,7 @@ export default function ApiKeysPage({
}) {
return (
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<H1>Developer Settings</H1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
@@ -19,7 +19,7 @@ import {
BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
} from "~/lib/constants/ses-errors";
} from "@usesend/lib/src/constants/ses-errors";
import CancelEmail from "./cancel-email";
import { useEffect } from "react";
import { useState } from "react";
@@ -75,7 +75,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
<span className="text-sm">
{formatDate(
emailQuery.data?.scheduledAt,
"MMM dd'th', hh:mm a"
"MMM dd'th', hh:mm a",
)}
</span>
<div className="ml-4">
@@ -0,0 +1,292 @@
"use client";
import { use, useState, useEffect } from "react";
import Link from "next/link";
import { api } from "~/trpc/react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import { Button } from "@usesend/ui/src/button";
import {
Edit3,
Key,
MoreVertical,
Pause,
Play,
TestTube,
CircleEllipsis,
} from "lucide-react";
import { toast } from "@usesend/ui/src/toaster";
import { WebhookInfo } from "./webhook-info";
import { WebhookCallsTable } from "./webhook-calls-table";
import { WebhookCallDetails } from "./webhook-call-details";
import { DeleteWebhook } from "../delete-webhook";
import { EditWebhookDialog } from "../webhook-update-dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@usesend/ui/src/popover";
import { type Webhook } from "@prisma/client";
function WebhookDetailActions({
webhook,
onTest,
onEdit,
onToggleStatus,
onRotateSecret,
isTestPending,
isToggling,
isRotating,
}: {
webhook: Webhook;
onTest: () => void;
onEdit: () => void;
onToggleStatus: () => void;
onRotateSecret: () => void;
isTestPending: boolean;
isToggling: boolean;
isRotating: boolean;
}) {
const [open, setOpen] = useState(false);
const isPaused = webhook.status === "PAUSED";
const isAutoDisabled = webhook.status === "AUTO_DISABLED";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="default" className="gap-1">
<MoreVertical className="h-4 -ml-2" />
Actions
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 rounded-xl p-1" align="end">
<div className="flex flex-col">
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onTest();
setOpen(false);
}}
disabled={isTestPending}
>
<TestTube className="mr-2 h-4 w-4" />
Test webhook
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onEdit();
setOpen(false);
}}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onToggleStatus();
setOpen(false);
}}
disabled={isToggling || isAutoDisabled}
>
{isPaused ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onRotateSecret();
setOpen(false);
}}
disabled={isRotating}
>
<Key className="mr-2 h-4 w-4" />
Rotate secret
</Button>
<DeleteWebhook webhook={webhook} />
</div>
</PopoverContent>
</Popover>
);
}
export default function WebhookDetailPage({
params,
}: {
params: Promise<{ webhookId: string }>;
}) {
const { webhookId } = use(params);
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const webhookQuery = api.webhook.getById.useQuery({ id: webhookId });
const testWebhook = api.webhook.test.useMutation();
const setStatusMutation = api.webhook.setStatus.useMutation();
const updateWebhook = api.webhook.update.useMutation();
const callsQuery = api.webhook.listCalls.useQuery({
webhookId,
limit: 50,
});
const utils = api.useUtils();
const webhook = webhookQuery.data;
useEffect(() => {
if (!selectedCallId && callsQuery.data?.items.length) {
setSelectedCallId(callsQuery.data.items[0]!.id);
}
}, [callsQuery.data, selectedCallId]);
const handleTest = () => {
testWebhook.mutate(
{ id: webhookId },
{
onSuccess: async () => {
await utils.webhook.listCalls.invalidate();
toast.success("Test webhook enqueued");
},
onError: (error) => {
toast.error(error.message);
},
},
);
};
const handleToggleStatus = (currentStatus: string) => {
const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
setStatusMutation.mutate(
{ id: webhookId, status: newStatus },
{
onSuccess: async () => {
await utils.webhook.getById.invalidate();
toast.success(
`Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
);
},
onError: (error) => {
toast.error(error.message);
},
},
);
};
const handleRotateSecret = () => {
updateWebhook.mutate(
{ id: webhookId, rotateSecret: true },
{
onSuccess: async () => {
await utils.webhook.getById.invalidate();
toast.success("Secret rotated successfully");
},
onError: (error) => {
toast.error(error.message);
},
},
);
};
if (webhookQuery.isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<p className="text-muted-foreground">Loading webhook...</p>
</div>
);
}
if (!webhook) {
return (
<div className="flex items-center justify-center h-screen">
<p className="text-muted-foreground">Webhook not found</p>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/webhooks" className="text-lg">
Webhooks
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg max-w-[500px] truncate">
{webhook.url}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<WebhookDetailActions
webhook={webhook}
onTest={handleTest}
onEdit={() => setIsEditDialogOpen(true)}
onToggleStatus={() => handleToggleStatus(webhook.status)}
onRotateSecret={handleRotateSecret}
isTestPending={testWebhook.isPending}
isToggling={setStatusMutation.isPending}
isRotating={updateWebhook.isPending}
/>
</div>
<WebhookInfo webhook={webhook} />
<div className="h-[calc(100vh-280px)] min-h-[600px] flex gap-6">
<div className="w-1/2 flex flex-col">
<WebhookCallsTable
webhookId={webhookId}
selectedCallId={selectedCallId}
onSelectCall={setSelectedCallId}
/>
</div>
<div className="w-1/2 overflow-auto">
{selectedCallId ? (
<WebhookCallDetails callId={selectedCallId} />
) : (
<div className="h-full flex items-center justify-center border rounded-xl bg-muted/10 border-dashed text-muted-foreground">
Select a webhook call to view details
</div>
)}
</div>
</div>
{isEditDialogOpen && (
<EditWebhookDialog
webhook={webhook}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
)}
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { formatDate } from "date-fns";
import { RefreshCw } from "lucide-react";
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
import { WEBHOOK_EVENT_VERSION } from "@usesend/lib/src/webhook/webhook-events";
import { CodeDisplay } from "~/components/code-display";
export function WebhookCallDetails({ callId }: { callId: string }) {
const callQuery = api.webhook.getCall.useQuery({ id: callId });
const retryMutation = api.webhook.retryCall.useMutation();
const utils = api.useUtils();
const call = callQuery.data;
if (!call) {
return (
<div className="h-full flex flex-col">
<div className="flex flex-row items-center justify-between mb-4">
<h2 className="text-base font-medium">Call Details</h2>
</div>
<div className="flex-1 rounded-xl border shadow p-6 flex items-center justify-center">
<p className="text-muted-foreground text-sm">
Loading call details...
</p>
</div>
</div>
);
}
const handleRetry = () => {
retryMutation.mutate(
{ id: call.id },
{
onSuccess: async () => {
await utils.webhook.listCalls.invalidate();
await utils.webhook.getCall.invalidate();
toast.success("Webhook call queued for retry");
},
onError: (error) => {
toast.error(error.message);
},
},
);
};
// Reconstruct the full payload that was actually sent to the webhook endpoint
const buildFullPayload = () => {
let data: unknown;
try {
data = JSON.parse(call.payload);
} catch {
data = call.payload;
}
return {
id: call.id,
type: call.type,
version: call.webhook?.apiVersion ?? WEBHOOK_EVENT_VERSION,
createdAt: new Date(call.createdAt).toISOString(),
teamId: call.teamId,
data,
attempt: call.attempt,
};
};
const fullPayload = buildFullPayload();
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex flex-row items-center justify-between mb-4">
<h2 className="text-base font-medium">Call Details</h2>
{call.status === "FAILED" && (
<Button
size="sm"
variant="outline"
onClick={handleRetry}
disabled={retryMutation.isPending}
className="h-8"
>
<RefreshCw className="h-3.5 w-3.5 mr-2" />
Retry
</Button>
)}
</div>
<div className="flex-1 overflow-auto rounded-xl border shadow p-6 space-y-8 no-scrollbar">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Status
</span>
<div>
<WebhookCallStatusBadge status={call.status} />
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Event Type
</span>
<span className="text-sm font-mono">{call.type}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Timestamp
</span>
<span className="text-sm font-mono">
{formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Attempt
</span>
<span className="text-sm font-mono">{call.attempt}</span>
</div>
{call.responseStatus && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Response Status
</span>
<span className="text-sm font-mono">{call.responseStatus}</span>
</div>
)}
{call.responseTimeMs != null && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Duration
</span>
<span className="text-sm font-mono">{call.responseTimeMs}ms</span>
</div>
)}
</div>
{call.lastError && (
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider text-red-500">
Error
</span>
<div className="text-xs bg-red-500/10 border border-red-500/20 rounded-md p-3 font-mono text-red-600 dark:text-red-400">
{call.lastError}
</div>
</div>
)}
<div className="flex flex-col gap-3">
<h4 className="font-medium text-sm">Request Payload</h4>
<CodeDisplay
code={JSON.stringify(fullPayload, null, 2)}
language="json"
/>
</div>
{call.responseText && (
<>
<div className="flex flex-col gap-3">
<h4 className="font-medium text-sm">Response Body</h4>
<CodeDisplay code={call.responseText} />
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { WebhookCallStatus } from "@prisma/client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { formatDistanceToNow } from "date-fns";
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
const PAGE_SIZE = 20;
export function WebhookCallsTable({
webhookId,
selectedCallId,
onSelectCall,
}: {
webhookId: string;
selectedCallId: string | null;
onSelectCall: (callId: string) => void;
}) {
const [statusFilter, setStatusFilter] = useState<WebhookCallStatus | "ALL">(
"ALL",
);
const [cursors, setCursors] = useState<string[]>([]);
const currentCursor = cursors[cursors.length - 1];
const callsQuery = api.webhook.listCalls.useQuery({
webhookId,
status: statusFilter === "ALL" ? undefined : statusFilter,
limit: PAGE_SIZE,
cursor: currentCursor,
});
const calls = callsQuery.data?.items ?? [];
const nextCursor = callsQuery.data?.nextCursor;
const handleNextPage = () => {
if (nextCursor) {
setCursors([...cursors, nextCursor]);
}
};
const handlePrevPage = () => {
setCursors(cursors.slice(0, -1));
};
const handleFilterChange = (value: WebhookCallStatus | "ALL") => {
setStatusFilter(value);
setCursors([]);
};
return (
<div className="h-full flex flex-col">
<div className="flex flex-row items-center justify-between mb-4">
<h2 className="text-base font-medium">Delivery Logs</h2>
<Select
value={statusFilter}
onValueChange={(value) =>
handleFilterChange(value as WebhookCallStatus | "ALL")
}
>
<SelectTrigger className="w-[150px] h-8 text-xs">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={WebhookCallStatus.DELIVERED}>
Delivered
</SelectItem>
<SelectItem value={WebhookCallStatus.FAILED}>Failed</SelectItem>
<SelectItem value={WebhookCallStatus.PENDING}>Pending</SelectItem>
<SelectItem value={WebhookCallStatus.IN_PROGRESS}>
In Progress
</SelectItem>
<SelectItem value={WebhookCallStatus.DISCARDED}>
Discarded
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-hidden rounded-xl border shadow flex flex-col">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted dark:bg-muted/70">
<TableHead className="h-9 rounded-tl-xl">Status</TableHead>
<TableHead className="h-9">Event Type</TableHead>
<TableHead className="h-9 rounded-tr-xl">Time</TableHead>
</TableRow>
</TableHeader>
</Table>
<div className="flex-1 overflow-auto no-scrollbar">
<Table>
<TableBody>
{callsQuery.isLoading ? (
<TableRow className="h-32 hover:bg-transparent">
<TableCell colSpan={3} className="py-4 text-center">
<Spinner
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : calls.length === 0 ? (
<TableRow className="h-32 hover:bg-transparent">
<TableCell colSpan={3} className="py-4 text-center">
<p className="text-muted-foreground text-sm">
No webhook calls yet
</p>
</TableCell>
</TableRow>
) : (
calls.map((call) => (
<TableRow
key={call.id}
className={`cursor-pointer transition-colors ${
selectedCallId === call.id
? "bg-accent/50 text-accent-foreground"
: "hover:bg-muted/50"
}`}
onClick={() => onSelectCall(call.id)}
>
<TableCell className="py-2">
<div className="scale-90 origin-left">
<WebhookCallStatusBadge status={call.status} />
</div>
</TableCell>
<TableCell className="py-2 font-mono text-xs">
{call.type}
</TableCell>
<TableCell className="py-2 text-xs text-muted-foreground">
{formatDistanceToNow(call.createdAt, {
addSuffix: true,
})}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex gap-4 justify-end mt-4">
<Button
size="sm"
onClick={handlePrevPage}
disabled={cursors.length === 0}
>
Previous
</Button>
<Button size="sm" onClick={handleNextPage} disabled={!nextCursor}>
Next
</Button>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { Webhook, WebhookCallStatus } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import { Copy, Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import { toast } from "@usesend/ui/src/toaster";
import { api } from "~/trpc/react";
import { Badge } from "@usesend/ui/src/badge";
import { WebhookStatusBadge } from "../webhook-status-badge";
export function WebhookInfo({ webhook }: { webhook: Webhook }) {
const [showSecret, setShowSecret] = useState(false);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const callsQuery = api.webhook.listCalls.useQuery({
webhookId: webhook.id,
limit: 50,
});
const calls = callsQuery.data?.items ?? [];
const last7DaysCalls = calls.filter(
(call) => new Date(call.createdAt) >= sevenDaysAgo,
);
const deliveredCount = last7DaysCalls.filter(
(c) => c.status === WebhookCallStatus.DELIVERED,
).length;
const failedCount = last7DaysCalls.filter(
(c) => c.status === WebhookCallStatus.FAILED,
).length;
const pendingCount = last7DaysCalls.filter(
(c) =>
c.status === WebhookCallStatus.PENDING ||
c.status === WebhookCallStatus.IN_PROGRESS,
).length;
const handleCopySecret = () => {
navigator.clipboard.writeText(webhook.secret);
toast.success("Secret copied to clipboard");
};
return (
<div className="flex items-start gap-6 justify-between mt-5 mb-10">
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Events</span>
<div className="flex items-center gap-1 flex-wrap font-mono text-sm">
{webhook.eventTypes.length === 0 ? (
<span className="text-sm">All events</span>
) : (
<>
{webhook.eventTypes.slice(0, 2).map((event) => (
<Badge key={event} variant="outline">
{event}
</Badge>
))}
{webhook.eventTypes.length > 2 && (
<span className="text-xs text-muted-foreground">
+{webhook.eventTypes.length - 2} more
</span>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Status</span>
<div className="flex items-center">
<WebhookStatusBadge status={webhook.status} />
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Signing Secret</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSecret(!showSecret)}
className="h-5 w-5 text-muted-foreground hover:text-foreground"
>
{showSecret ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCopySecret}
className="h-5 w-5 text-muted-foreground hover:text-foreground"
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono w-[240px] inline-block truncate">
{showSecret ? webhook.secret : "whsec_••••••••••••••••••••••••"}
</code>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { CodeBlock } from "@usesend/ui/src/code-block";
interface WebhookPayloadDisplayProps {
payload: string;
title: string;
lang?: "json" | "text";
}
export async function WebhookPayloadDisplay({
payload,
title,
lang = "json",
}: WebhookPayloadDisplayProps) {
let displayContent = payload;
// For JSON, try to pretty-print it
if (lang === "json") {
try {
const parsed = JSON.parse(payload);
displayContent = JSON.stringify(parsed, null, 2);
} catch {
// If parsing fails, use as-is
displayContent = payload;
}
}
return (
<div className="flex flex-col gap-3">
<h4 className="font-medium text-sm">{title}</h4>
<div className="rounded-lg overflow-hidden border">
<CodeBlock lang={lang} className="text-xs max-h-[300px] overflow-auto">
{displayContent}
</CodeBlock>
</div>
</div>
);
}
@@ -0,0 +1,333 @@
"use client";
import { useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { api } from "~/trpc/react";
import { useForm } from "react-hook-form";
import { toast } from "@usesend/ui/src/toaster";
import { ChevronDown, Plus } from "lucide-react";
import {
ContactEvents,
DomainEvents,
EmailEvents,
WebhookEvents,
type WebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@usesend/ui/src/dropdown-menu";
import { LimitReason } from "~/lib/constants/plans";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
const webhookSchema = z.object({
url: z
.string({ required_error: "URL is required" })
.url("Please enter a valid URL"),
eventTypes: z.array(EVENT_TYPES_ENUM, {
required_error: "Select at least one event",
}),
});
type WebhookFormValues = z.infer<typeof webhookSchema>;
const eventGroups: {
label: string;
events: readonly WebhookEventType[];
}[] = [
{ label: "Contact events", events: ContactEvents },
{ label: "Domain events", events: DomainEvents },
{ label: "Email events", events: EmailEvents },
];
export function AddWebhook() {
const [open, setOpen] = useState(false);
const [allEventsSelected, setAllEventsSelected] = useState(false);
const createWebhookMutation = api.webhook.create.useMutation();
const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK });
const { openModal } = useUpgradeModalStore((s) => s.action);
const utils = api.useUtils();
const form = useForm<WebhookFormValues>({
resolver: zodResolver(webhookSchema),
defaultValues: {
url: "",
eventTypes: [],
},
});
function onOpenChange(nextOpen: boolean) {
if (nextOpen && limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
setOpen(nextOpen);
}
function handleSubmit(values: WebhookFormValues) {
if (limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
const selectedEvents = values.eventTypes ?? [];
if (!allEventsSelected && selectedEvents.length === 0) {
toast.error("Select at least one event or all events");
return;
}
createWebhookMutation.mutate(
{
url: values.url,
eventTypes: allEventsSelected ? [] : selectedEvents,
},
{
onSuccess: async () => {
await utils.webhook.list.invalidate();
form.reset({
url: "",
eventTypes: [],
});
setAllEventsSelected(false);
setOpen(false);
toast.success("Webhook created successfully");
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) =>
nextOpen !== open ? onOpenChange(nextOpen) : null
}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-1 h-4 w-4" />
Add webhook
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create a new webhook</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="url"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhooks/usesend"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTypes"
render={({ field, formState }) => {
const selectedEvents = field.value ?? [];
const totalEvents = WebhookEvents;
const selectedCount = allEventsSelected
? totalEvents.length
: selectedEvents.length;
const allSelectedLabel =
selectedCount === 0
? "Select events"
: allEventsSelected
? "All events"
: selectedCount === 1
? selectedEvents[0]
: `${selectedCount} events selected`;
const isGroupFullySelected = (
groupEvents: readonly WebhookEventType[],
) => {
if (allEventsSelected) return true;
if (selectedEvents.length === 0) return false;
return groupEvents.every((event) =>
selectedEvents.includes(event),
);
};
const handleToggleAll = (checked: boolean) => {
if (checked) {
setAllEventsSelected(true);
field.onChange([]);
} else {
setAllEventsSelected(false);
field.onChange([]);
}
};
const handleToggleGroup = (
groupEvents: readonly WebhookEventType[],
) => {
if (allEventsSelected) {
const next = totalEvents.filter(
(event) => !groupEvents.includes(event),
);
setAllEventsSelected(false);
field.onChange(next);
return;
}
const current = new Set(selectedEvents);
const fullySelected = groupEvents.every((event) =>
current.has(event),
);
if (fullySelected) {
groupEvents.forEach((event) => current.delete(event));
} else {
groupEvents.forEach((event) => current.add(event));
}
field.onChange(Array.from(current));
};
const handleToggleEvent = (event: WebhookEventType) => {
if (allEventsSelected) {
const next = WebhookEvents.filter((e) => e !== event);
setAllEventsSelected(false);
field.onChange(next);
return;
}
const exists = selectedEvents.includes(event);
const next = exists
? selectedEvents.filter((e) => e !== event)
: [...selectedEvents, event];
field.onChange(next);
};
return (
<FormItem>
<FormLabel>Events</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 inline-flex w-full items-center justify-between"
>
<span className="truncate text-left text-sm">
{allSelectedLabel}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh] ">
<div className="space-y-3">
<DropdownMenuCheckboxItem
checked={allEventsSelected}
onCheckedChange={(checked) =>
handleToggleAll(Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
className="font-medium mb-2 px-2"
>
All events
</DropdownMenuCheckboxItem>
{eventGroups.map((group) => (
<div key={group.label} className="">
<DropdownMenuCheckboxItem
checked={isGroupFullySelected(group.events)}
onCheckedChange={() =>
handleToggleGroup(group.events)
}
onSelect={(event) => event.preventDefault()}
className="px-2 text-xs font-semibold text-muted-foreground"
>
{group.label}
</DropdownMenuCheckboxItem>
{group.events.map((event) => (
<DropdownMenuCheckboxItem
key={event}
checked={
allEventsSelected ||
selectedEvents.includes(event)
}
onCheckedChange={() =>
handleToggleEvent(event)
}
onSelect={(event) =>
event.preventDefault()
}
className="pl-3 pr-2 font-mono"
>
{event}
</DropdownMenuCheckboxItem>
))}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
{formState.errors.eventTypes ? <FormMessage /> : null}
</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button
className="w-[120px]"
type="submit"
disabled={createWebhookMutation.isPending}
>
{createWebhookMutation.isPending ? "Creating..." : "Create"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,61 @@
"use client";
import { Button } from "@usesend/ui/src/button";
import { DeleteResource } from "~/components/DeleteResource";
import { api } from "~/trpc/react";
import { type Webhook } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { Trash2 } from "lucide-react";
export const DeleteWebhook: React.FC<{
webhook: Webhook;
}> = ({ webhook }) => {
const deleteWebhookMutation = api.webhook.delete.useMutation();
const utils = api.useUtils();
const schema = z
.object({
confirmation: z.string().min(1, "Please type the webhook URL to confirm"),
})
.refine((data) => data.confirmation === webhook.url, {
message: "Webhook URL does not match",
path: ["confirmation"],
});
async function onConfirm(values: z.infer<typeof schema>) {
deleteWebhookMutation.mutate(
{ id: webhook.id },
{
onSuccess: async () => {
await utils.webhook.list.invalidate();
toast.success("Webhook deleted");
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<DeleteResource
title="Delete webhook"
resourceName={webhook.url}
schema={schema}
isLoading={deleteWebhookMutation.isPending}
onConfirm={onConfirm}
confirmLabel="Delete webhook"
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start rounded-lg text-red/80 hover:bg-accent hover:text-red"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
}
/>
);
};
@@ -0,0 +1,17 @@
"use client";
import { H1 } from "@usesend/ui";
import { AddWebhook } from "./add-webhook";
import { WebhookList } from "./webhook-list";
export default function WebhooksPage() {
return (
<div>
<div className="flex items-center justify-between">
<H1>Webhooks</H1>
<AddWebhook />
</div>
<WebhookList />
</div>
);
}
@@ -0,0 +1,41 @@
import { WebhookCallStatus } from "@prisma/client";
export function WebhookCallStatusBadge({
status,
}: {
status: WebhookCallStatus;
}) {
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
let label: string = status;
switch (status) {
case WebhookCallStatus.DELIVERED:
badgeColor = "bg-green/15 text-green border border-green/20";
label = "Delivered";
break;
case WebhookCallStatus.FAILED:
badgeColor = "bg-red/15 text-red border border-red/20";
label = "Failed";
break;
case WebhookCallStatus.PENDING:
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
label = "Pending";
break;
case WebhookCallStatus.IN_PROGRESS:
badgeColor = "bg-blue/15 text-blue border border-blue/20";
label = "In Progress";
break;
case WebhookCallStatus.DISCARDED:
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
label = "Discarded";
break;
}
return (
<div
className={`text-center w-[110px] rounded capitalize py-1 text-xs ${badgeColor}`}
>
{label}
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { formatDistanceToNow } from "date-fns";
import { Edit3, MoreVertical, Pause, Play } from "lucide-react";
import { Button } from "@usesend/ui/src/button";
import { toast } from "@usesend/ui/src/toaster";
import { DeleteWebhook } from "./delete-webhook";
import { useState } from "react";
import { EditWebhookDialog } from "./webhook-update-dialog";
import { useRouter } from "next/navigation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@usesend/ui/src/popover";
import { type Webhook } from "@prisma/client";
import { WebhookStatusBadge } from "./webhook-status-badge";
export function WebhookList() {
const webhooksQuery = api.webhook.list.useQuery();
const testWebhook = api.webhook.test.useMutation();
const setStatusMutation = api.webhook.setStatus.useMutation();
const utils = api.useUtils();
const router = useRouter();
const [editingId, setEditingId] = useState<string | null>(null);
const webhooks = webhooksQuery.data ?? [];
async function handleToggleStatus(webhookId: string, currentStatus: string) {
const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
setStatusMutation.mutate(
{ id: webhookId, status: newStatus },
{
onSuccess: async () => {
await utils.webhook.list.invalidate();
toast.success(
`Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
);
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<div className="mt-10">
<div className="rounded-xl border shadow">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">URL</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last success</TableHead>
<TableHead>Last failure</TableHead>
<TableHead className="rounded-tr-xl text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooksQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="py-4 text-center">
<Spinner
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : webhooks.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="py-4 text-center">
<p>No webhooks configured</p>
</TableCell>
</TableRow>
) : (
webhooks.map((webhook) => (
<TableRow
key={webhook.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/webhooks/${webhook.id}`)}
>
<TableCell className="max-w-xs truncate ">
{webhook.url}
</TableCell>
<TableCell>
<WebhookStatusBadge status={webhook.status} />
</TableCell>
<TableCell className="text-sm">
{webhook.lastSuccessAt
? formatDistanceToNow(webhook.lastSuccessAt, {
addSuffix: true,
})
: "Never"}
</TableCell>
<TableCell className="text-sm">
{webhook.lastFailureAt
? formatDistanceToNow(webhook.lastFailureAt, {
addSuffix: true,
})
: "Never"}
</TableCell>
<TableCell className="text-right">
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<WebhookActions
webhook={webhook}
onEdit={() => setEditingId(webhook.id)}
onToggleStatus={() =>
handleToggleStatus(webhook.id, webhook.status)
}
isToggling={setStatusMutation.isPending}
/>
</div>
{editingId === webhook.id ? (
<EditWebhookDialog
webhook={webhook}
open={editingId === webhook.id}
onOpenChange={(open) =>
setEditingId(open ? webhook.id : null)
}
/>
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
function WebhookActions({
webhook,
onEdit,
onToggleStatus,
isToggling,
}: {
webhook: Webhook;
onEdit: () => void;
onToggleStatus: () => void;
isToggling: boolean;
}) {
const [open, setOpen] = useState(false);
const isPaused = webhook.status === "PAUSED";
const isAutoDisabled = webhook.status === "AUTO_DISABLED";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 rounded-xl p-1" align="end">
<div className="flex flex-col">
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onEdit();
setOpen(false);
}}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start rounded-lg hover:bg-accent"
onClick={() => {
onToggleStatus();
setOpen(false);
}}
disabled={isToggling || isAutoDisabled}
>
{isPaused ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause
</>
)}
</Button>
<DeleteWebhook webhook={webhook} />
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,25 @@
import { WebhookStatus } from "@prisma/client";
export function WebhookStatusBadge({ status }: { status: WebhookStatus }) {
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
let label: string = status;
if (status === WebhookStatus.ACTIVE) {
badgeColor = "bg-green/15 text-green border border-green/20";
label = "Active";
} else if (status === WebhookStatus.PAUSED) {
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
label = "Paused";
} else if (status === WebhookStatus.AUTO_DISABLED) {
badgeColor = "bg-red/15 text-red border border-red/20";
label = "Auto disabled";
}
return (
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
>
{label}
</div>
);
}
@@ -0,0 +1,326 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import { Button } from "@usesend/ui/src/button";
import { ChevronDown } from "lucide-react";
import { api } from "~/trpc/react";
import {
ContactEvents,
DomainEvents,
EmailEvents,
WebhookEvents,
type WebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@usesend/ui/src/dropdown-menu";
import { toast } from "@usesend/ui/src/toaster";
import type { Webhook } from "@prisma/client";
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
const editWebhookSchema = z.object({
url: z
.string({ required_error: "URL is required" })
.url("Please enter a valid URL"),
eventTypes: z.array(EVENT_TYPES_ENUM, {
required_error: "Select at least one event",
}),
});
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
const eventGroups: {
label: string;
events: readonly WebhookEventType[];
}[] = [
{ label: "Contact events", events: ContactEvents },
{ label: "Domain events", events: DomainEvents },
{ label: "Email events", events: EmailEvents },
];
export function EditWebhookDialog({
webhook,
open,
onOpenChange,
}: {
webhook: Webhook;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const updateWebhook = api.webhook.update.useMutation();
const utils = api.useUtils();
const initialHasAllEvents =
(webhook.eventTypes as WebhookEventType[]).length === 0;
const [allEventsSelected, setAllEventsSelected] =
useState(initialHasAllEvents);
const form = useForm<EditWebhookFormValues>({
resolver: zodResolver(editWebhookSchema),
defaultValues: {
url: webhook.url,
eventTypes: initialHasAllEvents
? []
: (webhook.eventTypes as WebhookEventType[]),
},
});
useEffect(() => {
if (open) {
const hasAllEvents =
(webhook.eventTypes as WebhookEventType[]).length === 0;
form.reset({
url: webhook.url,
eventTypes: hasAllEvents
? []
: (webhook.eventTypes as WebhookEventType[]),
});
setAllEventsSelected(hasAllEvents);
}
}, [open, webhook, form]);
function handleSubmit(values: EditWebhookFormValues) {
const selectedEvents = values.eventTypes ?? [];
if (!allEventsSelected && selectedEvents.length === 0) {
toast.error("Select at least one event or all events");
return;
}
updateWebhook.mutate(
{
id: webhook.id,
url: values.url,
eventTypes: allEventsSelected ? [] : selectedEvents,
},
{
onSuccess: async () => {
await utils.webhook.list.invalidate();
await utils.webhook.getById.invalidate({ id: webhook.id });
toast.success("Webhook updated");
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit webhook</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="url"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhooks/usesend"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTypes"
render={({ field, formState }) => {
const selectedEvents = field.value ?? [];
const totalEvents = WebhookEvents;
const selectedCount = allEventsSelected
? totalEvents.length
: selectedEvents.length;
const allSelectedLabel =
selectedCount === 0
? "Select events"
: allEventsSelected
? "All events"
: selectedCount === 1
? selectedEvents[0]
: `${selectedCount} events selected`;
const isGroupFullySelected = (
groupEvents: readonly WebhookEventType[],
) => {
if (allEventsSelected) return true;
if (selectedEvents.length === 0) return false;
return groupEvents.every((event) =>
selectedEvents.includes(event),
);
};
const handleToggleAll = (checked: boolean) => {
if (checked) {
setAllEventsSelected(true);
field.onChange([]);
} else {
setAllEventsSelected(false);
field.onChange([]);
}
};
const handleToggleGroup = (
groupEvents: readonly WebhookEventType[],
) => {
if (allEventsSelected) {
const next = totalEvents.filter(
(event) => !groupEvents.includes(event),
);
setAllEventsSelected(false);
field.onChange(next);
return;
}
const current = new Set(selectedEvents);
const fullySelected = groupEvents.every((event) =>
current.has(event),
);
if (fullySelected) {
groupEvents.forEach((event) => current.delete(event));
} else {
groupEvents.forEach((event) => current.add(event));
}
field.onChange(Array.from(current));
};
const handleToggleEvent = (event: WebhookEventType) => {
if (allEventsSelected) {
const next = WebhookEvents.filter((e) => e !== event);
setAllEventsSelected(false);
field.onChange(next);
return;
}
const exists = selectedEvents.includes(event);
const next = exists
? selectedEvents.filter((e) => e !== event)
: [...selectedEvents, event];
field.onChange(next);
};
return (
<FormItem>
<FormLabel>Events</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 inline-flex w-full items-center justify-between"
>
<span className="truncate text-left text-sm">
{allSelectedLabel}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh]">
<div className="space-y-3">
<DropdownMenuCheckboxItem
checked={allEventsSelected}
onCheckedChange={(checked) =>
handleToggleAll(Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
className="font-medium mb-2 px-2"
>
All events
</DropdownMenuCheckboxItem>
{eventGroups.map((group) => (
<div key={group.label} className="">
<DropdownMenuCheckboxItem
checked={isGroupFullySelected(group.events)}
onCheckedChange={() =>
handleToggleGroup(group.events)
}
onSelect={(event) => event.preventDefault()}
className="px-2 text-xs font-semibold text-muted-foreground"
>
{group.label}
</DropdownMenuCheckboxItem>
{group.events.map((event) => (
<DropdownMenuCheckboxItem
key={event}
checked={
allEventsSelected ||
selectedEvents.includes(event)
}
onCheckedChange={() =>
handleToggleEvent(event)
}
onSelect={(event) =>
event.preventDefault()
}
className="pl-3 pr-2 font-mono"
>
{event}
</DropdownMenuCheckboxItem>
))}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
{formState.errors.eventTypes ? <FormMessage /> : null}
</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button
className="w-[120px]"
type="submit"
disabled={updateWebhook.isPending}
>
{updateWebhook.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}