feat(web): allow multiple webhook subscriptions

This commit is contained in:
isra el
2026-05-24 13:35:59 +03:00
parent c952040b2b
commit 336bb65e0a
8 changed files with 220 additions and 59 deletions

View File

@@ -21,6 +21,7 @@ import {
Ellipsis,
MessageSquare,
Smartphone,
Webhook,
} from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
import ProductClient from '../webhooks/(components)/webhook-table'
@@ -54,7 +55,16 @@ const WebhooksHistory = () => {
.then((res) => res.data),
})
const { data: webhooks, isLoading: isLoadingWebhooks } = useQuery({
queryKey: ['webhooks'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getWebhooks())
.then((res) => res.data),
})
const [currentDevice, setCurrentDevice] = useState('all')
const [currentWebhook, setCurrentWebhook] = useState('all')
const [eventType, setEventType] = useState('all')
const [status, setStatus] = useState('all')
const [dateRange, setDateRange] = useState<any>('90')
@@ -84,6 +94,7 @@ const WebhooksHistory = () => {
page,
limit,
currentDevice,
currentWebhook,
status,
],
enabled: true,
@@ -94,6 +105,8 @@ const WebhooksHistory = () => {
dateQuery.start
}&end=${dateQuery.end}&deviceId=${
currentDevice === 'all' ? '' : currentDevice
}&webhookSubscriptionId=${
currentWebhook === 'all' ? '' : currentWebhook
}`
)
.then((res) => res.data),
@@ -151,6 +164,11 @@ const WebhooksHistory = () => {
setPage(1)
}
const handleWebhookChange = (webhookId: string) => {
setCurrentWebhook(webhookId)
setPage(1)
}
const message_events = [
'MESSAGE_RECEIVED',
'MESSAGE_SENT',
@@ -197,6 +215,43 @@ const WebhooksHistory = () => {
</Select>
</div>
<div className="w-full sm:w-56">
<div className="flex items-center gap-2 mb-1.5">
<Webhook className="h-3.5 w-3.5 text-brand-500" />
<h3 className="text-sm font-medium text-foreground">Webhook</h3>
</div>
<Select
value={currentWebhook}
onValueChange={handleWebhookChange}
>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70">
<SelectValue placeholder="Select a webhook" />
</SelectTrigger>
<SelectContent>
<SelectItem key="all" value="all">
All webhooks
</SelectItem>
{webhooks?.data?.map((webhook: any) => (
<SelectItem key={webhook._id} value={webhook._id}>
<div className="flex items-center gap-2">
<span className="font-medium truncate max-w-[180px]">
{webhook.name?.trim() || webhook.deliveryUrl}
</span>
{!webhook.isActive && (
<Badge
variant="outline"
className="ml-1 text-xs py-0 h-5"
>
Inactive
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-44">
<div className="flex items-center gap-2 mb-1.5">
<MessageSquare className="h-3.5 w-3.5 text-brand-500" />

View File

@@ -42,6 +42,10 @@ import { useToast } from '@/hooks/use-toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
const formSchema = z.object({
name: z
.string()
.max(64, { message: 'Name must be 64 characters or fewer' })
.optional(),
deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }),
events: z.array(z.string()).min(1, { message: 'Select at least one event' }),
isActive: z.boolean().default(true),
@@ -63,6 +67,7 @@ export function CreateWebhookDialog({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
deliveryUrl: '',
events: [WEBHOOK_EVENTS.MESSAGE_RECEIVED],
isActive: true,
@@ -71,8 +76,16 @@ export function CreateWebhookDialog({
})
const createWebhookMutation = useMutation({
mutationFn: (values: z.infer<typeof formSchema>) =>
httpBrowserClient.post(ApiEndpoints.gateway.createWebhook(), values),
mutationFn: (values: z.infer<typeof formSchema>) => {
const payload = {
...values,
name: values.name?.trim() ? values.name.trim() : undefined,
}
return httpBrowserClient.post(
ApiEndpoints.gateway.createWebhook(),
payload,
)
},
onSuccess: () => {
toast({
title: 'Success',
@@ -82,10 +95,11 @@ export function CreateWebhookDialog({
onOpenChange(false)
form.reset()
},
onError: () => {
onError: (error: any) => {
toast({
title: 'Error',
description: 'Failed to create webhook',
description:
error?.response?.data?.message || 'Failed to create webhook',
variant: 'destructive',
})
},
@@ -118,6 +132,26 @@ export function CreateWebhookDialog({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name (optional)</FormLabel>
<FormControl>
<Input
placeholder='e.g. Production CRM'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
A short label to help you tell your webhooks apart
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='deliveryUrl'

View File

@@ -13,16 +13,55 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useToast } from '@/hooks/use-toast'
interface DeleteWebhookButtonProps {
onDelete: () => void
webhookId: string
webhookLabel?: string
onDeleted?: () => void
}
export function DeleteWebhookButton({ onDelete }: DeleteWebhookButtonProps) {
export function DeleteWebhookButton({
webhookId,
webhookLabel,
onDeleted,
}: DeleteWebhookButtonProps) {
const [open, setOpen] = useState(false)
const queryClient = useQueryClient()
const { toast } = useToast()
const { mutate: deleteWebhook, isPending } = useMutation({
mutationFn: () =>
httpBrowserClient.delete(ApiEndpoints.gateway.deleteWebhook(webhookId)),
onSuccess: () => {
toast({
title: 'Webhook deleted',
description: webhookLabel
? `"${webhookLabel}" has been removed.`
: 'The webhook has been removed.',
})
queryClient.invalidateQueries({ queryKey: ['webhooks'] })
setOpen(false)
onDeleted?.()
},
onError: (error: any) => {
toast({
title: 'Error',
description:
error?.response?.data?.message || 'Failed to delete webhook',
variant: 'destructive',
})
},
})
return (
<AlertDialog>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant='outline' size='sm' disabled>
<Button variant='outline' size='sm' disabled={isPending}>
<Trash2 className='h-4 w-4 text-destructive' />
</Button>
</AlertDialogTrigger>
@@ -30,18 +69,24 @@ export function DeleteWebhookButton({ onDelete }: DeleteWebhookButtonProps) {
<AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this webhook? This action cannot be
undone.
{webhookLabel
? `Are you sure you want to delete "${webhookLabel}"? `
: 'Are you sure you want to delete this webhook? '}
New events will no longer be delivered to this endpoint. Past
delivery history will be preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onDelete}
disabled
onClick={(e) => {
e.preventDefault()
deleteWebhook()
}}
disabled={isPending}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
{isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -43,6 +43,10 @@ import {
} from '@/components/ui/dropdown-menu'
const formSchema = z.object({
name: z
.string()
.max(64, { message: 'Name must be 64 characters or fewer' })
.optional(),
deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }),
events: z.array(z.string()).min(1, { message: 'Select at least one event' }),
isActive: z.boolean().default(true),
@@ -66,6 +70,7 @@ export function EditWebhookDialog({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: {
name: webhook.name ?? '',
deliveryUrl: webhook.deliveryUrl,
events: webhook.events,
isActive: webhook.isActive,
@@ -75,9 +80,13 @@ export function EditWebhookDialog({
const { mutate: updateWebhook, isPending } = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) => {
const payload = {
...values,
name: values.name?.trim() ? values.name.trim() : '',
}
return httpBrowserClient.patch(
ApiEndpoints.gateway.updateWebhook(webhook._id),
values
payload,
)
},
onSuccess: () => {
@@ -89,10 +98,11 @@ export function EditWebhookDialog({
queryClient.invalidateQueries({ queryKey: ['webhooks'] })
onOpenChange(false)
},
onError: () => {
onError: (error: any) => {
toast({
title: 'Error',
description: 'Failed to update webhook',
description:
error?.response?.data?.message || 'Failed to update webhook',
variant: 'destructive',
})
},
@@ -124,6 +134,26 @@ export function EditWebhookDialog({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name (optional)</FormLabel>
<FormControl>
<Input
placeholder='e.g. Production CRM'
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
A short label to help you tell your webhooks apart
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='deliveryUrl'

View File

@@ -17,10 +17,10 @@ import { useQueryClient } from '@tanstack/react-query'
interface WebhookCardProps {
webhook: WebhookData
onEdit: () => void
onDelete?: () => void
onDeleted?: () => void
}
export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) {
export function WebhookCard({ webhook, onEdit, onDeleted }: WebhookCardProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const queryClient = useQueryClient()
@@ -68,7 +68,9 @@ export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) {
<CardHeader className='flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4'>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<h3 className='text-base font-semibold'>Webhook Endpoint</h3>
<h3 className='text-base font-semibold'>
{webhook.name?.trim() ? webhook.name : 'Webhook Endpoint'}
</h3>
<Badge variant={webhook.isActive ? 'default' : 'secondary'}>
{webhook.isActive ? 'Active' : 'Inactive'}
</Badge>
@@ -78,8 +80,8 @@ export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) {
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Switch
checked={webhook.isActive}
<Switch
checked={webhook.isActive}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
@@ -87,7 +89,11 @@ export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) {
<Edit2 className='h-4 w-4 sm:mr-2' />
<span className='hidden sm:inline'>Edit</span>
</Button>
<DeleteWebhookButton onDelete={onDelete} />
<DeleteWebhookButton
webhookId={webhook._id ?? ''}
webhookLabel={webhook.name?.trim() || webhook.deliveryUrl}
onDeleted={onDeleted}
/>
</div>
</CardHeader>
<CardContent>

View File

@@ -12,13 +12,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
function WebhookCardSkeleton() {
@@ -56,6 +49,8 @@ function WebhookCardSkeleton() {
)
}
const MAX_WEBHOOKS_PER_USER = 5
export default function WebhooksSection() {
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
@@ -86,6 +81,9 @@ export default function WebhooksSection() {
setEditDialogOpen(true)
}
const webhookCount = webhooks?.data?.length ?? 0
const reachedLimit = webhookCount >= MAX_WEBHOOKS_PER_USER
return (
<div className='container mx-auto py-4 sm:py-8 px-4 sm:px-6'>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8'>
@@ -95,35 +93,25 @@ export default function WebhooksSection() {
Webhooks
</h1>
<p className='text-sm text-muted-foreground mt-2'>
Manage webhook notifications for your SMS events
Manage webhook notifications for your SMS events. You can configure
up to {MAX_WEBHOOKS_PER_USER} webhooks.
</p>
</div>
<div className='flex gap-x-4'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
onClick={handleCreateClick}
disabled={webhooks?.data?.length > 0 || isLoading}
variant='default'
className='w-full sm:w-auto'
>
<PlusCircle className='mr-2 h-4 w-4' />
Create Webhook
</Button>
</div>
</TooltipTrigger>
{webhooks?.data?.length > 0 && (
<TooltipContent>
<p>
You already have an active webhook subscription. You can
edit or manage the existing webhook instead.
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<Button
onClick={handleCreateClick}
disabled={reachedLimit || isLoading}
variant='default'
className='w-full sm:w-auto'
title={
reachedLimit
? `You have reached the maximum of ${MAX_WEBHOOKS_PER_USER} webhooks. Delete one to add a new one.`
: undefined
}
>
<PlusCircle className='mr-2 h-4 w-4' />
Create Webhook
</Button>
<Button
onClick={() => navigator.push('/dashboard/webhooks')}
variant='default'
@@ -159,11 +147,12 @@ export default function WebhooksSection() {
) : (
<div className='bg-muted/50 rounded-lg p-8 text-center'>
<h3 className='text-lg font-medium mb-2'>
No webhook configured
No webhooks configured
</h3>
<p className='text-muted-foreground mb-4'>
Create a webhook to receive real-time notifications for SMS
events
Add a webhook endpoint to receive real-time notifications for
SMS events. You can add multiple endpoints for different
services.
</p>
<Button onClick={handleCreateClick} variant='default'>
<PlusCircle className='mr-2 h-4 w-4' />

View File

@@ -36,6 +36,7 @@ export const ApiEndpoints = {
getWebhookNotifications: () => '/webhooks/notifications',
createWebhook: () => '/webhooks',
updateWebhook: (id: string) => `/webhooks/${id}`,
deleteWebhook: (id: string) => `/webhooks/${id}`,
getStats: () => '/gateway/stats',
},
billing: {

View File

@@ -1,5 +1,6 @@
export interface WebhookData {
_id?: string
name?: string
deliveryUrl: string
events: string[]
isActive: boolean