From c63c1b8963a291cd6323d0bf478f758173ba56e0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 24 Nov 2025 18:43:59 +1100 Subject: [PATCH] feat: add webhook logs --- .../dialogs/webhook-create-dialog.tsx | 3 +- .../dialogs/webhook-delete-dialog.tsx | 32 +- .../dialogs/webhook-edit-dialog.tsx | 225 ++++++++++ .../dialogs/webhook-test-dialog.tsx | 9 +- .../components/general/webhook-logs-sheet.tsx | 198 +++++++++ ...bhooks.$id.tsx => webhooks.$id._index.tsx} | 2 +- .../settings.webhooks.$id._index.tsx | 392 ++++++++++++++++++ .../t.$teamUrl+/settings.webhooks.$id.tsx | 263 ------------ .../t.$teamUrl+/settings.webhooks._index.tsx | 247 +++++++---- .../e2e/webhooks/webhooks-crud.spec.ts | 377 +++++++++++++++++ .../internal/execute-webhook.handler.ts | 1 + .../webhook-router/find-webhook-calls.ts | 106 +++++ .../find-webhook-calls.types.ts | 37 ++ .../webhook-router/resend-webhook-call.ts | 80 ++++ .../resend-webhook-call.types.ts | 26 ++ packages/trpc/server/webhook-router/router.ts | 56 ++- packages/trpc/server/webhook-router/schema.ts | 10 - packages/ui/primitives/data-table.tsx | 11 +- 18 files changed, 1664 insertions(+), 411 deletions(-) create mode 100644 apps/remix/app/components/dialogs/webhook-edit-dialog.tsx create mode 100644 apps/remix/app/components/general/webhook-logs-sheet.tsx rename apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/{webhooks.$id.tsx => webhooks.$id._index.tsx} (84%) create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx delete mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx create mode 100644 packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts create mode 100644 packages/trpc/server/webhook-router/find-webhook-calls.ts create mode 100644 packages/trpc/server/webhook-router/find-webhook-calls.types.ts create mode 100644 packages/trpc/server/webhook-router/resend-webhook-call.ts create mode 100644 packages/trpc/server/webhook-router/resend-webhook-call.types.ts diff --git a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx index ce1109322..da0a5f04c 100644 --- a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -38,7 +38,7 @@ import { useCurrentTeam } from '~/providers/team'; import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; -const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true }); +const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema; type TCreateWebhookFormSchema = z.infer; @@ -78,7 +78,6 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr eventTriggers, secret, webhookUrl, - teamId: team.id, }); setOpen(false); diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 6fb369577..27f51c7c4 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const onSubmit = async () => { try { - await deleteWebhook({ id: webhook.id, teamId: team.id }); + await deleteWebhook({ id: webhook.id }); toast({ title: _(msg`Webhook deleted`), @@ -146,26 +146,18 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr /> -
- + - -
+
diff --git a/apps/remix/app/components/dialogs/webhook-edit-dialog.tsx b/apps/remix/app/components/dialogs/webhook-edit-dialog.tsx new file mode 100644 index 000000000..4d6aa1c2e --- /dev/null +++ b/apps/remix/app/components/dialogs/webhook-edit-dialog.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type { Webhook } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; + +const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true }); + +type TEditWebhookFormSchema = z.infer; + +export type WebhookEditDialogProps = { + trigger?: React.ReactNode; + webhook: Webhook; +} & Omit; + +export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZEditWebhookFormSchema), + values: { + webhookUrl: webhook?.webhookUrl ?? '', + eventTriggers: webhook?.eventTriggers ?? [], + secret: webhook?.secret ?? '', + enabled: webhook?.enabled ?? true, + }, + }); + + const onSubmit = async (data: TEditWebhookFormSchema) => { + try { + await updateWebhook({ + id: webhook.id, + ...data, + }); + + toast({ + title: t`Webhook updated`, + description: t`The webhook has been updated successfully.`, + duration: 5000, + }); + } catch (err) { + toast({ + title: t`Failed to update webhook`, + description: t`We encountered an error while updating the webhook. Please try again later.`, + variant: 'destructive', + }); + } + }; + + return ( + !form.formState.isSubmitting && setOpen(value)} + {...props} + > + e.stopPropagation()} asChild> + {trigger} + + + + + + Edit webhook + + {webhook.id} + + +
+ +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + + Enabled + + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + + Triggers + + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + Secret + + + + + + + A secret that will be sent to your URL so you can verify that the request + has been sent by Documenso. + + + + + )} + /> + + + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/webhook-test-dialog.tsx b/apps/remix/app/components/dialogs/webhook-test-dialog.tsx index a4a8cb3a8..f63142e88 100644 --- a/apps/remix/app/components/dialogs/webhook-test-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-test-dialog.tsx @@ -36,8 +36,6 @@ import { } from '@documenso/ui/primitives/select'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useCurrentTeam } from '~/providers/team'; - export type WebhookTestDialogProps = { webhook: Pick; children: React.ReactNode; @@ -53,8 +51,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps) const { t } = useLingui(); const { toast } = useToast(); - const team = useCurrentTeam(); - const [open, setOpen] = useState(false); const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation(); @@ -71,7 +67,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps) await testWebhook({ id: webhook.id, event, - teamId: team.id, }); toast({ @@ -150,11 +145,11 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps) diff --git a/apps/remix/app/components/general/webhook-logs-sheet.tsx b/apps/remix/app/components/general/webhook-logs-sheet.tsx new file mode 100644 index 000000000..50941ee77 --- /dev/null +++ b/apps/remix/app/components/general/webhook-logs-sheet.tsx @@ -0,0 +1,198 @@ +import { useMemo, useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { WebhookCallStatus } from '@prisma/client'; +import { RotateCwIcon } from 'lucide-react'; +import { createCallable } from 'react-call'; + +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { trpc } from '@documenso/trpc/react'; +import type { TFindWebhookLogsResponse } from '@documenso/trpc/server/webhook-router/find-webhook-logs.types'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type WebhookLogsSheetProps = { + webhookCall: TFindWebhookLogsResponse['data'][number]; +}; + +export const WebhookLogsSheet = createCallable( + ({ call, webhookCall: initialWebhookCall }) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [webhookCall, setWebhookCall] = useState(initialWebhookCall); + + const [activeTab, setActiveTab] = useState<'request' | 'response'>('request'); + + const { mutateAsync: resendWebhookCall, isPending: isResending } = + trpc.webhook.calls.resend.useMutation({ + onSuccess: (result) => { + toast({ title: t`Webhook successfully sent` }); + + setWebhookCall(result); + }, + onError: () => { + toast({ title: t`Something went wrong` }); + }, + }); + + const generalWebhookDetails = useMemo(() => { + return [ + { + header: t`Status`, + value: webhookCall.status === WebhookCallStatus.SUCCESS ? t`Success` : t`Failed`, + }, + { + header: t`Event`, + value: toFriendlyWebhookEventName(webhookCall.event), + }, + { + header: t`Sent`, + value: new Date(webhookCall.createdAt).toLocaleString(), + }, + { + header: t`Response Code`, + value: webhookCall.responseCode, + }, + { + header: t`Destination`, + value: webhookCall.url, + }, + ]; + }, [webhookCall]); + + return ( + (!value ? call.end(null) : null)}> + + +

+ Webhook Details +

+

{webhookCall.id}

+
+ + {/* Content */} +
+
+
+

+ Details +

+ + +
+
+ + + {generalWebhookDetails.map(({ header, value }, index) => ( + + + + + ))} + +
+ {header} + + {value} +
+
+
+ + {/* Payload Tabs */} +
+
+ + + +
+ +
+
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+                  {JSON.stringify(
+                    activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
+                    null,
+                    2,
+                  )}
+                
+
+ + {activeTab === 'response' && ( +
+

+ Response Headers +

+
+ + + {Object.entries(webhookCall.responseHeaders as Record).map( + ([key, value]) => ( + + + + + ), + )} + +
+ {key} + + {value as string} +
+
+
+ )} +
+
+
+
+ ); + }, +); diff --git a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id.tsx b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id._index.tsx similarity index 84% rename from apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id.tsx rename to apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id._index.tsx index 3125a210c..dd692083a 100644 --- a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/webhooks.$id._index.tsx @@ -1,4 +1,4 @@ -import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id'; +import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index'; export { meta }; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx new file mode 100644 index 000000000..999d7f550 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx @@ -0,0 +1,392 @@ +import { useMemo, useState } from 'react'; +import { useEffect } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; +import { + CheckCircle2Icon, + ChevronRightIcon, + PencilIcon, + TerminalIcon, + XCircleIcon, +} from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { useLocation, useSearchParams } from 'react-router'; +import { Link } from 'react-router'; +import { z } from 'zod'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { trpc } from '@documenso/trpc/react'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { WebhookEditDialog } from '~/components/dialogs/webhook-edit-dialog'; +import { WebhookTestDialog } from '~/components/dialogs/webhook-test-dialog'; +import { GenericErrorLayout } from '~/components/general/generic-error-layout'; +import { SettingsHeader } from '~/components/general/settings-header'; +import { WebhookLogsSheet } from '~/components/general/webhook-logs-sheet'; +import { useCurrentTeam } from '~/providers/team'; +import { appMetaTags } from '~/utils/meta'; + +import type { Route } from './+types/settings.webhooks.$id._index'; + +const WebhookSearchParamsSchema = ZUrlSearchParamsSchema.extend({ + status: z.nativeEnum(WebhookCallStatus).optional(), + events: z.preprocess( + (value) => (typeof value === 'string' && value.length > 0 ? value.split(',') : []), + z.array(z.nativeEnum(WebhookTriggerEvents)).optional(), + ), +}); + +export function meta() { + return appMetaTags('Webhooks'); +} + +export default function WebhookPage({ params }: Route.ComponentProps) { + const { t, i18n } = useLingui(); + const { toast } = useToast(); + + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + const team = useCurrentTeam(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const parsedSearchParams = WebhookSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( + { + id: params.id, + }, + { enabled: !!params.id, retry: false }, + ); + + const { + data, + isLoading: isLogsLoading, + isLoadingError: isLogsLoadingError, + } = trpc.webhook.calls.find.useQuery({ + webhookId: params.id, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + status: parsedSearchParams.status, + events: parsedSearchParams.events, + query: parsedSearchParams.query, + }); + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + // If nothing to change then do nothing. + if (params.toString() === searchParams?.toString()) { + return; + } + + setSearchParams(params); + }, [debouncedSearchQuery, pathname, searchParams]); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: t`Status`, + accessorKey: 'status', + + cell: ({ row }) => ( + + {row.original.status === 'SUCCESS' ? ( + + ) : ( + + )} + {row.original.responseCode} + + ), + }, + { + header: t`Event`, + accessorKey: 'event', + cell: ({ row }) => ( +
+

+ {toFriendlyWebhookEventName(row.original.event)} +

+

{row.original.id}

+
+ ), + }, + { + header: t`Sent`, + accessorKey: 'createdAt', + cell: ({ row }) => ( +
+

+ {i18n.date(row.original.createdAt, { + timeStyle: 'short', + dateStyle: 'short', + })} +

+ +
+ +
+
+ ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + const getTabHref = (value: string) => { + const params = new URLSearchParams(searchParams); + + params.set('status', value); + + if (value === '') { + params.delete('status'); + } + + if (params.has('page')) { + params.delete('page'); + } + + let path = pathname; + + if (params.toString()) { + path += `?${params.toString()}`; + } + + return path; + }; + + if (isLoading) { + return ; + } + + // Todo: Update UI, currently out of place. + if (!webhook) { + return ( + + + Go back + + + } + secondaryButton={null} + /> + ); + } + + return ( +
+ +

+ Webhook +

+ + {webhook.enabled ? Enabled : Disabled} + +
+ } + subtitle={webhook.webhookUrl} + > +
+ + + + + + + Edit + + } + /> +
+ + +
+
+ setSearchQuery(e.target.value)} + placeholder={t`Search by ID`} + /> + + + + + + + + All + + + + + Success + + + + + Failed + + + + +
+ + + WebhookLogsSheet.call({ + webhookCall: row, + }) + } + rowClassName="cursor-pointer group" + error={{ + enable: isLogsLoadingError, + }} + skeleton={{ + enable: isLogsLoading, + rows: 3, + component: ( + <> + + + + + + + + + + + ), + }} + > + {(table) => + results.totalPages > 1 && ( + + ) + } + +
+ + + + ); +} + +const WebhookEventCombobox = () => { + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const isMounted = useIsMounted(); + + const events = (searchParams?.get('events') ?? '').split(',').filter((value) => value !== ''); + + const comboBoxOptions = Object.values(WebhookTriggerEvents).map((event) => ({ + label: toFriendlyWebhookEventName(event), + value: event, + })); + + const onChange = (newEvents: string[]) => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('events', newEvents.join(',')); + + if (newEvents.length === 0) { + params.delete('events'); + } + + void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true }); + }; + + return ( + + + Events: All + +

+ } + enableClearAllButton={true} + inputPlaceholder={msg`Search`} + loading={!isMounted} + options={comboBoxOptions} + selectedValues={events} + onChange={onChange} + /> + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx deleted file mode 100644 index dbe8409b1..000000000 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { useForm } from 'react-hook-form'; -import { useRevalidator } from 'react-router'; -import { Link } from 'react-router'; -import type { z } from 'zod'; - -import { trpc } from '@documenso/trpc/react'; -import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema'; -import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { PasswordInput } from '@documenso/ui/primitives/password-input'; -import { SpinnerBox } from '@documenso/ui/primitives/spinner'; -import { Switch } from '@documenso/ui/primitives/switch'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { WebhookTestDialog } from '~/components/dialogs/webhook-test-dialog'; -import { GenericErrorLayout } from '~/components/general/generic-error-layout'; -import { SettingsHeader } from '~/components/general/settings-header'; -import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox'; -import { useCurrentTeam } from '~/providers/team'; -import { appMetaTags } from '~/utils/meta'; - -import type { Route } from './+types/settings.webhooks.$id'; - -const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true, teamId: true }); - -type TEditWebhookFormSchema = z.infer; - -export function meta() { - return appMetaTags('Webhooks'); -} - -export default function WebhookPage({ params }: Route.ComponentProps) { - const { _ } = useLingui(); - const { toast } = useToast(); - const { revalidate } = useRevalidator(); - - const team = useCurrentTeam(); - - const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( - { - id: params.id, - teamId: team.id, - }, - { enabled: !!params.id && !!team.id }, - ); - - const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation(); - - const form = useForm({ - resolver: zodResolver(ZEditWebhookFormSchema), - values: { - webhookUrl: webhook?.webhookUrl ?? '', - eventTriggers: webhook?.eventTriggers ?? [], - secret: webhook?.secret ?? '', - enabled: webhook?.enabled ?? true, - }, - }); - - const onSubmit = async (data: TEditWebhookFormSchema) => { - try { - await updateWebhook({ - id: params.id, - teamId: team.id, - ...data, - }); - - toast({ - title: _(msg`Webhook updated`), - description: _(msg`The webhook has been updated successfully.`), - duration: 5000, - }); - - await revalidate(); - } catch (err) { - toast({ - title: _(msg`Failed to update webhook`), - description: _( - msg`We encountered an error while updating the webhook. Please try again later.`, - ), - variant: 'destructive', - }); - } - }; - - if (isLoading) { - return ; - } - - // Todo: Update UI, currently out of place. - if (!webhook) { - return ( - - - Go back - - - } - secondaryButton={null} - /> - ); - } - - return ( -
- - -
- -
-
- ( - - Webhook URL - - - - - - The URL for Documenso to send webhook events to. - - - - - )} - /> - - ( - - - Enabled - - -
- - - -
- - -
- )} - /> -
- - ( - - - Triggers - - - { - onChange(values); - }} - /> - - - - The events that will trigger a webhook to be sent to your URL. - - - - - )} - /> - - ( - - Secret - - - - - - - A secret that will be sent to your URL so you can verify that the request has - been sent by Documenso. - - - - - )} - /> - -
- -
-
-
- - - -
- - Test Webhook - - - - Send a test webhook with sample data to verify your integration is working correctly. - - -
- -
- - - -
-
-
- ); -} diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx index 39b2553b7..0e020e17f 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -1,7 +1,18 @@ +import { useMemo } from 'react'; + import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; +import { Plural, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; -import { Loader } from 'lucide-react'; +import type { Webhook } from '@prisma/client'; +import { + CheckCircle2Icon, + EditIcon, + Loader, + MoreHorizontalIcon, + ScrollTextIcon, + Trash2Icon, + XCircleIcon, +} from 'lucide-react'; import { DateTime } from 'luxon'; import { Link } from 'react-router'; @@ -10,9 +21,21 @@ import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Badge } from '@documenso/ui/primitives/badge'; import { Button } from '@documenso/ui/primitives/button'; +import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog'; import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog'; +import { WebhookEditDialog } from '~/components/dialogs/webhook-edit-dialog'; import { SettingsHeader } from '~/components/general/settings-header'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -22,19 +45,72 @@ export function meta() { } export default function WebhookPage() { - const { _, i18n } = useLingui(); + const { t, i18n } = useLingui(); const team = useCurrentTeam(); - const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({ - teamId: team.id, - }); + const { data, isLoading, isError } = trpc.webhook.getTeamWebhooks.useQuery(); + + const results = { + data: data ?? [], + perPage: 0, + currentPage: 0, + totalPages: 0, + }; + + const columns = useMemo(() => { + return [ + { + header: t`Webhook`, + cell: ({ row }) => ( + +

{row.original.id}

+

+ {row.original.webhookUrl} +

+ + ), + }, + { + header: t`Status`, + cell: ({ row }) => ( + + {row.original.enabled ? Enabled : Disabled} + + ), + }, + { + header: t`Listening to`, + cell: ({ row }) => ( +

toFriendlyWebhookEventName(event)) + .join(', ')} + > + +

+ ), + }, + { + header: t`Created`, + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + header: t`Actions`, + cell: ({ row }) => , + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); return (
@@ -43,74 +119,95 @@ export default function WebhookPage() {
)} - {webhooks && webhooks.length === 0 && ( - // TODO: Perhaps add some illustrations here to make the page more engaging -
-

- - You have no webhooks yet. Your webhooks will be shown here once you create them. - -

-
- )} - {webhooks && webhooks.length > 0 && ( -
- {webhooks?.map((webhook) => ( -
-
-
-
{webhook.id}
-
-
- {webhook.webhookUrl} -
- - - {webhook.enabled ? Enabled : Disabled} - -
- -

- - Listening to{' '} - {webhook.eventTriggers - .map((trigger) => toFriendlyWebhookEventName(trigger)) - .join(', ')} - -

- -

- Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)} -

-
- -
- - - - -
-
-
- ))} -
- )} + +

+ + You have no webhooks yet. Your webhooks will be shown here once you create them. + +

+ + } + skeleton={{ + enable: isLoading, + rows: 3, + component: ( + <> + + + + + + + + + + + + + + + + + ), + }} + /> ); } + +const WebhookTableActionDropdown = ({ webhook }: { webhook: Webhook }) => { + const team = useCurrentTeam(); + + return ( + + + + + + + + Action + + + + + + Logs + + + + e.preventDefault()}> +
+ + Edit +
+ + } + /> + + + e.preventDefault()}> +
+ + Delete +
+
+
+
+
+ ); +}; diff --git a/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts b/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts new file mode 100644 index 000000000..3e6043335 --- /dev/null +++ b/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts @@ -0,0 +1,377 @@ +import { expect, test } from '@playwright/test'; +import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { expectTextToBeVisible } from '../fixtures/generic'; + +/** + * Helper function to seed a webhook directly in the database for testing. + */ +const seedWebhook = async ({ + webhookUrl, + eventTriggers, + secret, + enabled, + userId, + teamId, +}: { + webhookUrl: string; + eventTriggers: WebhookTriggerEvents[]; + secret?: string | null; + enabled?: boolean; + userId: number; + teamId: number; +}) => { + return await prisma.webhook.create({ + data: { + webhookUrl, + eventTriggers, + secret: secret ?? null, + enabled: enabled ?? true, + userId, + teamId, + }, + }); +}; + +test('[WEBHOOKS]: create webhook', async ({ page }) => { + const { user, team } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings/webhooks`, + }); + + const webhookUrl = `https://example.com/webhook-${Date.now()}`; + + // Click Create Webhook button + await page.getByRole('button', { name: 'Create Webhook' }).click(); + + // Fill in the form + await page.getByLabel('Webhook URL*').fill(webhookUrl); + + // Select event trigger - click on the triggers field and select DOCUMENT_CREATED + await page.getByLabel('Triggers').click(); + await page.waitForTimeout(200); // Wait for dropdown to open + await page.getByText('document.created').click(); + + // Click outside the triggers field to close the dropdown + await page.getByText('The URL for Documenso to send webhook events to.').click(); + + // Fill in the form + await page.getByLabel('Secret').fill('secret'); + + // Submit the form + await page.getByRole('button', { name: 'Create' }).click(); + + // Wait for success toast + await expectTextToBeVisible(page, 'Webhook created'); + await expectTextToBeVisible(page, 'The webhook was successfully created.'); + + // Verify webhook appears in the list + await expect(page.getByText(webhookUrl)).toBeVisible(); + + // Directly check database + const dbWebhook = await prisma.webhook.findFirstOrThrow({ + where: { + userId: user.id, + }, + }); + + expect(dbWebhook?.eventTriggers).toEqual([WebhookTriggerEvents.DOCUMENT_CREATED]); + expect(dbWebhook?.secret).toBe('secret'); + expect(dbWebhook?.enabled).toBe(true); +}); + +test('[WEBHOOKS]: view webhooks', async ({ page }) => { + const { user, team } = await seedUser(); + + const webhookUrl = `https://example.com/webhook-${Date.now()}`; + + // Create a webhook via seeding + const webhook = await seedWebhook({ + webhookUrl, + eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT], + userId: user.id, + teamId: team.id, + enabled: true, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings/webhooks`, + }); + + // Verify webhook is visible in the table + await expect(page.getByText(webhookUrl)).toBeVisible(); + await expect(page.getByText('Enabled')).toBeVisible(); + await expect(page.getByText('2 Events')).toBeVisible(); + + // Click on webhook to navigate to detail page + await page.getByText(webhookUrl).click(); + + // Verify detail page shows webhook information + await page.waitForURL(`/t/${team.url}/settings/webhooks/${webhook.id}`); + await expect(page.getByText(webhookUrl)).toBeVisible(); + await expect(page.getByText('Enabled')).toBeVisible(); +}); + +test('[WEBHOOKS]: delete webhook', async ({ page }) => { + const { user, team } = await seedUser(); + + const webhookUrl = `https://example.com/webhook-${Date.now()}`; + + // Create a webhook via seeding + const webhook = await seedWebhook({ + webhookUrl, + eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED], + userId: user.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings/webhooks`, + }); + + // Verify webhook is visible + await expect(page.getByText(webhookUrl)).toBeVisible(); + + // Find the row with the webhook and click the action dropdown + const webhookRow = page.locator('tr', { hasText: webhookUrl }); + await webhookRow.getByTestId('webhook-table-action-btn').click(); + + // Click Delete menu item + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + // Fill in confirmation field + const deleteMessage = `delete ${webhookUrl}`; + // The label contains "Confirm by typing:" followed by the delete message + await page.getByLabel(/Confirm by typing/).fill(deleteMessage); + + // Click delete button + await page.getByRole('button', { name: 'Delete' }).click(); + + // Wait for success toast + await expectTextToBeVisible(page, 'Webhook deleted'); + await expectTextToBeVisible(page, 'The webhook has been successfully deleted.'); + + // Verify webhook is removed from the list + await expect(page.getByText(webhookUrl)).not.toBeVisible(); +}); + +test('[WEBHOOKS]: update webhook', async ({ page }) => { + const { user, team } = await seedUser(); + + const originalWebhookUrl = `https://example.com/webhook-original-${Date.now()}`; + const updatedWebhookUrl = `https://example.com/webhook-updated-${Date.now()}`; + + // Create a webhook via seeding with initial values + const webhook = await seedWebhook({ + webhookUrl: originalWebhookUrl, + eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT], + userId: user.id, + teamId: team.id, + enabled: true, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings/webhooks`, + }); + + // Verify webhook is visible with original values + await expect(page.getByText(originalWebhookUrl)).toBeVisible(); + await expect(page.getByText('Enabled')).toBeVisible(); + await expect(page.getByText('2 Events')).toBeVisible(); + + // Find the row with the webhook and click the action dropdown + const webhookRow = page.locator('tr', { hasText: originalWebhookUrl }); + await webhookRow.getByTestId('webhook-table-action-btn').click(); + + // Click Edit menu item + await page.getByRole('menuitem', { name: 'Edit' }).click(); + + // Wait for dialog to open + await page.waitForTimeout(200); + + // Change the webhook URL + await page.getByLabel('Webhook URL').clear(); + await page.getByLabel('Webhook URL').fill(updatedWebhookUrl); + + // Disable the webhook (toggle the switch) + const enabledSwitch = page.getByLabel('Enabled'); + const isChecked = await enabledSwitch.isChecked(); + if (isChecked) { + await enabledSwitch.click(); + } + + // Change the event triggers - remove one existing event and add a new one + // The selected items are shown as badges with remove buttons + // Remove one of the existing events (DOCUMENT_SENT) by clicking its remove button + const removeButtons = page.locator('button[aria-label="Remove"]'); + const removeButtonCount = await removeButtons.count(); + + // Remove the "DOCUMENT_SENT" event (this will remove one of the two) + if (removeButtonCount > 0) { + await removeButtons.nth(1).click(); + await page.waitForTimeout(200); + } + + // Add new event triggers + await page.getByLabel('Triggers').click(); + await page.waitForTimeout(200); + + // Select DOCUMENT_COMPLETED (this will be added to the remaining DOCUMENT_CREATED) + await page.getByText('document.completed').click(); + await page.waitForTimeout(200); + + // Click outside to close the dropdown + await page.getByText('The URL for Documenso to send webhook events to.').click(); + + // Submit the form + await page.getByRole('button', { name: 'Update' }).click(); + + // Wait for success toast + await expectTextToBeVisible(page, 'Webhook updated'); + await expectTextToBeVisible(page, 'The webhook has been updated successfully.'); + + // Verify changes are reflected in the list + // The old URL should be gone and new URL should be visible + await expect(page.getByText(originalWebhookUrl)).not.toBeVisible(); + await expect(page.getByText(updatedWebhookUrl)).toBeVisible(); + // Verify webhook is disabled + await expect(page.getByText('Disabled')).toBeVisible(); + // Verify event count is still 2 (one removed, one added - DOCUMENT_CREATED and DOCUMENT_COMPLETED) + await expect(page.getByText('2 Events')).toBeVisible(); + + // Check the database directly to verify + const dbWebhook = await prisma.webhook.findUnique({ + where: { + id: webhook.id, + }, + }); + + expect(dbWebhook?.eventTriggers).toEqual([ + WebhookTriggerEvents.DOCUMENT_CREATED, + WebhookTriggerEvents.DOCUMENT_COMPLETED, + ]); + expect(dbWebhook?.enabled).toBe(false); + expect(dbWebhook?.webhookUrl).toBe(updatedWebhookUrl); + expect(dbWebhook?.secret).toBe(''); +}); + +test('[WEBHOOKS]: cannot see unrelated webhooks', async ({ page }) => { + // Create two separate users with teams + const user1Data = await seedUser(); + const user2Data = await seedUser(); + + const webhookUrl1 = `https://example.com/webhook-team1-${Date.now()}`; + const webhookUrl2 = `https://example.com/webhook-team2-${Date.now()}`; + + // Create webhooks for both teams with DOCUMENT_CREATED event + const webhook1 = await seedWebhook({ + webhookUrl: webhookUrl1, + eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED], + userId: user1Data.user.id, + teamId: user1Data.team.id, + enabled: true, + }); + + const webhook2 = await seedWebhook({ + webhookUrl: webhookUrl2, + eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT], + userId: user2Data.user.id, + teamId: user2Data.team.id, + }); + + // Create a document on team1 to trigger the webhook + const document = await seedBlankDocument(user1Data.user, user1Data.team.id, { + createDocumentOptions: { + title: 'Test Document for Webhook', + }, + }); + + // Create a webhook call for team1's webhook (simulating the webhook being triggered) + // Since webhooks are triggered via jobs which may not run in tests, we create the call directly + const webhookCall1 = await prisma.webhookCall.create({ + data: { + webhookId: webhook1.id, + url: webhookUrl1, + event: WebhookTriggerEvents.DOCUMENT_CREATED, + status: WebhookCallStatus.SUCCESS, + responseCode: 200, + requestBody: { + event: WebhookTriggerEvents.DOCUMENT_CREATED, + payload: { + id: document.id, + title: document.title, + }, + createdAt: new Date().toISOString(), + webhookEndpoint: webhookUrl1, + }, + }, + }); + + // Sign in as user1 + await apiSignin({ + page, + email: user1Data.user.email, + redirectPath: `/t/${user1Data.team.url}/settings/webhooks`, + }); + + // Verify user1 can see their webhook + await expect(page.getByText(webhookUrl1)).toBeVisible(); + // Verify user1 cannot see user2's webhook + await expect(page.getByText(webhookUrl2)).not.toBeVisible(); + + // Navigate to team1's webhook logs page + await page.goto( + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${user1Data.team.url}/settings/webhooks/${webhook1.id}`, + ); + + // Verify user1 can see their webhook logs + // The webhook call should be visible in the table + await expect(page.getByText(webhookCall1.id)).toBeVisible(); + await expect(page.getByText('200')).toBeVisible(); // Response code + + // Sign out and sign in as user2 + await apiSignout({ page }); + await apiSignin({ + page, + email: user2Data.user.email, + redirectPath: `/t/${user2Data.team.url}/settings/webhooks`, + }); + + // Verify user2 can see their webhook + await expect(page.getByText(webhookUrl2)).toBeVisible(); + // Verify user2 cannot see user1's webhook + await expect(page.getByText(webhookUrl1)).not.toBeVisible(); + + // Navigate to team2's webhook logs page + await page.goto( + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook2.id}`, + ); + + // Verify user2 cannot see team1's webhook logs + // The webhook call from team1 should not be visible + await expect(page.getByText(webhookCall1.id)).not.toBeVisible(); + + // Attempt to access user1's webhook detail page directly via URL + await page.goto( + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook1.id}`, + ); + + // Verify access is denied - should show error or redirect + // Based on the component, it shows a 404 error page + await expect(page.getByRole('heading', { name: 'Webhook not found' })).toBeVisible(); +}); diff --git a/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts b/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts index a357ea51f..a7999e72e 100644 --- a/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts +++ b/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts @@ -30,6 +30,7 @@ export const run = async ({ webhookEndpoint: url, }; + // Note: This is duplicated in `resend-webhook-call.ts`. const response = await fetch(url, { method: 'POST', body: JSON.stringify(payloadData), diff --git a/packages/trpc/server/webhook-router/find-webhook-calls.ts b/packages/trpc/server/webhook-router/find-webhook-calls.ts new file mode 100644 index 000000000..fa3bc0966 --- /dev/null +++ b/packages/trpc/server/webhook-router/find-webhook-calls.ts @@ -0,0 +1,106 @@ +import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindWebhookCallsRequestSchema, + ZFindWebhookCallsResponseSchema, +} from './find-webhook-calls.types'; + +export const findWebhookCallsRoute = authenticatedProcedure + .input(ZFindWebhookCallsRequestSchema) + .output(ZFindWebhookCallsResponseSchema) + .query(async ({ input, ctx }) => { + const { webhookId, page, perPage, status, query, events } = input; + + ctx.logger.info({ + input: { webhookId, status }, + }); + + return await findWebhookCalls({ + userId: ctx.user.id, + teamId: ctx.teamId, + webhookId, + page, + perPage, + status, + query, + events, + }); + }); + +type FindWebhookCallsOptions = { + userId: number; + teamId: number; + webhookId: string; + page?: number; + perPage?: number; + status?: WebhookCallStatus; + events?: WebhookTriggerEvents[]; + query?: string; +}; + +export const findWebhookCalls = async ({ + userId, + teamId, + webhookId, + page = 1, + perPage = 20, + events, + query = '', + status, +}: FindWebhookCallsOptions) => { + const webhook = await prisma.webhook.findFirst({ + where: { + id: webhookId, + team: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM, + }), + }, + }); + + if (!webhook) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + const whereClause: Prisma.WebhookCallWhereInput = { + webhookId: webhook.id, + status, + id: query || undefined, + event: + events && events.length > 0 + ? { + in: events, + } + : undefined, + }; + + const [data, count] = await Promise.all([ + prisma.webhookCall.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + }), + prisma.webhookCall.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; +}; diff --git a/packages/trpc/server/webhook-router/find-webhook-calls.types.ts b/packages/trpc/server/webhook-router/find-webhook-calls.types.ts new file mode 100644 index 000000000..23a7cb55e --- /dev/null +++ b/packages/trpc/server/webhook-router/find-webhook-calls.types.ts @@ -0,0 +1,37 @@ +import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; +import { z } from 'zod'; + +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; +import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema'; + +export const ZFindWebhookCallsRequestSchema = ZFindSearchParamsSchema.extend({ + webhookId: z.string(), + status: z.nativeEnum(WebhookCallStatus).optional(), + events: z + .array(z.nativeEnum(WebhookTriggerEvents)) + .optional() + .refine((arr) => !arr || new Set(arr).size === arr.length, { + message: 'Events must be unique', + }), +}); + +export const ZFindWebhookCallsResponseSchema = ZFindResultResponse.extend({ + data: WebhookCallSchema.pick({ + webhookId: true, + status: true, + event: true, + id: true, + url: true, + responseCode: true, + createdAt: true, + }) + .extend({ + requestBody: z.unknown(), + responseHeaders: z.unknown().nullable(), + responseBody: z.unknown().nullable(), + }) + .array(), +}); + +export type TFindWebhookCallsRequest = z.infer; +export type TFindWebhookCallsResponse = z.infer; diff --git a/packages/trpc/server/webhook-router/resend-webhook-call.ts b/packages/trpc/server/webhook-router/resend-webhook-call.ts new file mode 100644 index 000000000..abb0ca34e --- /dev/null +++ b/packages/trpc/server/webhook-router/resend-webhook-call.ts @@ -0,0 +1,80 @@ +import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZResendWebhookCallRequestSchema, + ZResendWebhookCallResponseSchema, +} from './resend-webhook-call.types'; + +export const resendWebhookCallRoute = authenticatedProcedure + .input(ZResendWebhookCallRequestSchema) + .output(ZResendWebhookCallResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { webhookId, webhookCallId } = input; + + ctx.logger.info({ + input: { webhookId, webhookCallId }, + }); + + const webhookCall = await prisma.webhookCall.findFirst({ + where: { + id: webhookCallId, + webhook: { + id: webhookId, + team: buildTeamWhereQuery({ + teamId, + userId: user.id, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM, + }), + }, + }, + include: { + webhook: true, + }, + }); + + if (!webhookCall) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + const { webhook } = webhookCall; + + // Note: This is duplicated in `execute-webhook.handler.ts`. + const response = await fetch(webhookCall.url, { + method: 'POST', + body: JSON.stringify(webhookCall.requestBody), + headers: { + 'Content-Type': 'application/json', + 'X-Documenso-Secret': webhook.secret ?? '', + }, + }); + + const body = await response.text(); + + let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull; + + try { + responseBody = JSON.parse(body); + } catch (err) { + responseBody = body; + } + + return await prisma.webhookCall.update({ + where: { + id: webhookCall.id, + }, + data: { + status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED, + responseCode: response.status, + responseBody, + responseHeaders: Object.fromEntries(response.headers.entries()), + }, + }); + }); diff --git a/packages/trpc/server/webhook-router/resend-webhook-call.types.ts b/packages/trpc/server/webhook-router/resend-webhook-call.types.ts new file mode 100644 index 000000000..f34b9ebc6 --- /dev/null +++ b/packages/trpc/server/webhook-router/resend-webhook-call.types.ts @@ -0,0 +1,26 @@ +import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; +import { z } from 'zod'; + +import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema'; + +export const ZResendWebhookCallRequestSchema = z.object({ + webhookId: z.string(), + webhookCallId: z.string(), +}); + +export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({ + webhookId: true, + status: true, + event: true, + id: true, + url: true, + responseCode: true, + createdAt: true, +}).extend({ + requestBody: z.unknown(), + responseHeaders: z.unknown().nullable(), + responseBody: z.unknown().nullable(), +}); + +export type TResendWebhookRequest = z.infer; +export type TResendWebhookResponse = z.infer; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index f6edb25e3..fc404b8f7 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -6,66 +6,61 @@ import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-web import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook'; import { authenticatedProcedure, router } from '../trpc'; +import { findWebhookCallsRoute } from './find-webhook-calls'; +import { resendWebhookCallRoute } from './resend-webhook-call'; import { ZCreateWebhookRequestSchema, ZDeleteWebhookRequestSchema, ZEditWebhookRequestSchema, - ZGetTeamWebhooksRequestSchema, ZGetWebhookByIdRequestSchema, ZTriggerTestWebhookRequestSchema, } from './schema'; export const webhookRouter = router({ - getTeamWebhooks: authenticatedProcedure - .input(ZGetTeamWebhooksRequestSchema) - .query(async ({ ctx, input }) => { - const { teamId } = input; + calls: { + find: findWebhookCallsRoute, + resend: resendWebhookCallRoute, + }, - ctx.logger.info({ - input: { - teamId, - }, - }); + getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => { + ctx.logger.info({ + input: { + teamId: ctx.teamId, + }, + }); - return await getWebhooksByTeamId(teamId, ctx.user.id); - }), + return await getWebhooksByTeamId(ctx.teamId, ctx.user.id); + }), getWebhookById: authenticatedProcedure .input(ZGetWebhookByIdRequestSchema) .query(async ({ input, ctx }) => { - const { id, teamId } = input; + const { id } = input; ctx.logger.info({ input: { id, - teamId, }, }); return await getWebhookById({ id, userId: ctx.user.id, - teamId, + teamId: ctx.teamId, }); }), createWebhook: authenticatedProcedure .input(ZCreateWebhookRequestSchema) .mutation(async ({ input, ctx }) => { - const { enabled, eventTriggers, secret, webhookUrl, teamId } = input; - - ctx.logger.info({ - input: { - teamId, - }, - }); + const { enabled, eventTriggers, secret, webhookUrl } = input; return await createWebhook({ enabled, secret, webhookUrl, eventTriggers, - teamId, + teamId: ctx.teamId, userId: ctx.user.id, }); }), @@ -73,18 +68,17 @@ export const webhookRouter = router({ deleteWebhook: authenticatedProcedure .input(ZDeleteWebhookRequestSchema) .mutation(async ({ input, ctx }) => { - const { id, teamId } = input; + const { id } = input; ctx.logger.info({ input: { id, - teamId, }, }); return await deleteWebhookById({ id, - teamId, + teamId: ctx.teamId, userId: ctx.user.id, }); }), @@ -92,12 +86,11 @@ export const webhookRouter = router({ editWebhook: authenticatedProcedure .input(ZEditWebhookRequestSchema) .mutation(async ({ input, ctx }) => { - const { id, teamId, ...data } = input; + const { id, ...data } = input; ctx.logger.info({ input: { id, - teamId, }, }); @@ -105,20 +98,19 @@ export const webhookRouter = router({ id, data, userId: ctx.user.id, - teamId, + teamId: ctx.teamId, }); }), testWebhook: authenticatedProcedure .input(ZTriggerTestWebhookRequestSchema) .mutation(async ({ input, ctx }) => { - const { id, event, teamId } = input; + const { id, event } = input; ctx.logger.info({ input: { id, event, - teamId, }, }); @@ -126,7 +118,7 @@ export const webhookRouter = router({ id, event, userId: ctx.user.id, - teamId, + teamId: ctx.teamId, }); }), }); diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index 9ed84e3cf..c8c8f5851 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -1,12 +1,6 @@ import { WebhookTriggerEvents } from '@prisma/client'; import { z } from 'zod'; -export const ZGetTeamWebhooksRequestSchema = z.object({ - teamId: z.number(), -}); - -export type TGetTeamWebhooksRequestSchema = z.infer; - export const ZCreateWebhookRequestSchema = z.object({ webhookUrl: z.string().url(), eventTriggers: z @@ -14,14 +8,12 @@ export const ZCreateWebhookRequestSchema = z.object({ .min(1, { message: 'At least one event trigger is required' }), secret: z.string().nullable(), enabled: z.boolean(), - teamId: z.number(), }); export type TCreateWebhookFormSchema = z.infer; export const ZGetWebhookByIdRequestSchema = z.object({ id: z.string(), - teamId: z.number(), }); export type TGetWebhookByIdRequestSchema = z.infer; @@ -34,7 +26,6 @@ export type TEditWebhookRequestSchema = z.infer; @@ -42,7 +33,6 @@ export type TDeleteWebhookRequestSchema = z.infer; diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index 2fad7e048..22a2bf12a 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -21,6 +21,8 @@ export interface DataTableProps { columns: ColumnDef[]; columnVisibility?: VisibilityState; data: TData[]; + onRowClick?: (row: TData) => void; + rowClassName?: string; perPage?: number; currentPage?: number; totalPages?: number; @@ -52,6 +54,8 @@ export function DataTable({ hasFilters, onClearFilters, onPaginationChange, + onRowClick, + rowClassName, children, emptyState, }: DataTableProps) { @@ -116,7 +120,12 @@ export function DataTable({ {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + onRowClick?.(row.original)} + > {row.getVisibleCells().map((cell) => (