mirror of
https://github.com/vernu/textbee.git
synced 2026-06-11 01:09:42 -04:00
feat(web): allow multiple webhook subscriptions
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface WebhookData {
|
||||
_id?: string
|
||||
name?: string
|
||||
deliveryUrl: string
|
||||
events: string[]
|
||||
isActive: boolean
|
||||
|
||||
Reference in New Issue
Block a user