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 (
+
+ );
+};
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 (
-
-
-
-
-
-
-
-
-
- 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) => (