From 122e25b4911ca8e3dd7c8dd2de6226b18c0f959d Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Mon, 14 Jul 2025 08:13:56 +0300 Subject: [PATCH] feat: test webhook functionality (#1886) --- .../dialogs/webhook-test-dialog.tsx | 170 ++++++ .../t.$teamUrl+/settings.webhooks.$id.tsx | 74 ++- .../t.$teamUrl+/settings.webhooks._index.tsx | 2 +- .../server-only/webhooks/get-webhook-by-id.ts | 25 +- .../webhooks/trigger-test-webhook.ts | 44 ++ .../webhooks/trigger/generate-sample-data.ts | 485 ++++++++++++++++++ packages/lib/types/webhook-payload.ts | 9 +- packages/trpc/server/webhook-router/router.ts | 23 + packages/trpc/server/webhook-router/schema.ts | 8 + 9 files changed, 808 insertions(+), 32 deletions(-) create mode 100644 apps/remix/app/components/dialogs/webhook-test-dialog.tsx create mode 100644 packages/lib/server-only/webhooks/trigger-test-webhook.ts create mode 100644 packages/lib/server-only/webhooks/trigger/generate-sample-data.ts diff --git a/apps/remix/app/components/dialogs/webhook-test-dialog.tsx b/apps/remix/app/components/dialogs/webhook-test-dialog.tsx new file mode 100644 index 000000000..7023e08a1 --- /dev/null +++ b/apps/remix/app/components/dialogs/webhook-test-dialog.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { Webhook } from '@prisma/client'; +import { WebhookTriggerEvents } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} 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; +}; + +const ZTestWebhookFormSchema = z.object({ + event: z.nativeEnum(WebhookTriggerEvents), +}); + +type TTestWebhookFormSchema = z.infer; + +export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZTestWebhookFormSchema), + defaultValues: { + event: webhook.eventTriggers[0], + }, + }); + + const onSubmit = async ({ event }: TTestWebhookFormSchema) => { + try { + await testWebhook({ + id: webhook.id, + event, + teamId: team.id, + }); + + toast({ + title: _(msg`Test webhook sent`), + description: _(msg`The test webhook has been successfully sent to your endpoint.`), + duration: 5000, + }); + + setOpen(false); + } catch (error) { + toast({ + title: _(msg`Test webhook failed`), + description: _( + msg`We encountered an error while sending the test webhook. Please check your endpoint and try again.`, + ), + variant: 'destructive', + duration: 5000, + }); + } + }; + + return ( + !form.formState.isSubmitting && setOpen(value)}> + {children} + + + + + Test Webhook + + + + + Send a test webhook with sample data to verify your integration is working correctly. + + + + +
+ +
+ ( + + + Event Type + + + + + )} + /> + +
+

+ Webhook URL +

+

{webhook.webhookUrl}

+
+ + + + + + +
+
+ +
+
+ ); +}; 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 index 63d10148e..6b8bd91b5 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx @@ -2,13 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { Loader } from 'lucide-react'; 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, @@ -21,9 +22,12 @@ import { } 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'; @@ -92,25 +96,45 @@ export default function WebhookPage({ params }: Route.ComponentProps) { } }; + if (isLoading) { + return ; + } + + // Todo: Update UI, currently out of place. + if (!webhook) { + return ( + + + Go back + + + } + secondaryButton={null} + /> + ); + } + return ( -
+
- {isLoading && ( -
- -
- )} -
-
+
-
+
@@ -211,6 +235,30 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
+ + +
+ + 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 c9e71d3ab..39b2553b7 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 @@ -54,7 +54,7 @@ export default function WebhookPage() {
)} {webhooks && webhooks.length > 0 && ( -
+
{webhooks?.map((webhook) => (
{ + const webhook = await getWebhookById({ id, userId, teamId }); + + if (!webhook.enabled) { + throw new Error('Webhook is disabled'); + } + + if (!webhook.eventTriggers.includes(event)) { + throw new Error(`Webhook does not support event: ${event}`); + } + + const samplePayload = generateSampleWebhookPayload(event, webhook.webhookUrl); + + try { + await triggerWebhook({ + event, + data: samplePayload, + userId, + teamId, + }); + + return { success: true, message: 'Test webhook triggered successfully' }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +}; diff --git a/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts b/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts new file mode 100644 index 000000000..73916c491 --- /dev/null +++ b/packages/lib/server-only/webhooks/trigger/generate-sample-data.ts @@ -0,0 +1,485 @@ +import { + DocumentDistributionMethod, + DocumentSigningOrder, + DocumentSource, + DocumentStatus, + DocumentVisibility, + ReadStatus, + RecipientRole, + SendStatus, + SigningStatus, + WebhookTriggerEvents, +} from '@prisma/client'; + +import type { WebhookPayload } from '../../../types/webhook-payload'; + +export const generateSampleWebhookPayload = ( + event: WebhookTriggerEvents, + webhookUrl: string, +): WebhookPayload => { + const now = new Date(); + const basePayload = { + id: 10, + externalId: null, + userId: 1, + authOptions: null, + formValues: null, + visibility: DocumentVisibility.EVERYONE, + title: 'documenso.pdf', + status: DocumentStatus.DRAFT, + documentDataId: 'hs8qz1ktr9204jn7mg6c5dxy0', + createdAt: now, + updatedAt: now, + completedAt: null, + deletedAt: null, + teamId: null, + templateId: null, + source: DocumentSource.DOCUMENT, + documentMeta: { + id: 'doc_meta_123', + subject: 'Please sign this document', + message: 'Hello, please review and sign this document.', + timezone: 'UTC', + password: null, + dateFormat: 'MM/DD/YYYY', + redirectUrl: null, + signingOrder: DocumentSigningOrder.PARALLEL, + allowDictateNextSigner: false, + typedSignatureEnabled: true, + uploadSignatureEnabled: true, + drawSignatureEnabled: true, + language: 'en', + distributionMethod: DocumentDistributionMethod.EMAIL, + emailSettings: null, + }, + recipients: [ + { + id: 52, + documentId: 10, + templateId: null, + email: 'signer@documenso.com', + name: 'John Doe', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: null, + signingOrder: 1, + rejectionReason: null, + role: RecipientRole.SIGNER, + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.NOT_SENT, + }, + ], + Recipient: [ + { + id: 52, + documentId: 10, + templateId: null, + email: 'signer@documenso.com', + name: 'John Doe', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: null, + signingOrder: 1, + rejectionReason: null, + role: RecipientRole.SIGNER, + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.NOT_SENT, + }, + ], + }; + + if (event === WebhookTriggerEvents.DOCUMENT_CREATED) { + return { + event, + payload: { + ...basePayload, + status: DocumentStatus.DRAFT, + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + if (event === WebhookTriggerEvents.DOCUMENT_SENT) { + return { + event, + payload: { + ...basePayload, + status: DocumentStatus.PENDING, + recipients: [ + { + ...basePayload.recipients[0], + email: 'signer2@documenso.com', + name: 'Signer 2', + role: RecipientRole.VIEWER, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: null, + signingOrder: 1, + rejectionReason: null, + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + }, + ], + Recipient: [ + { + ...basePayload.Recipient[0], + email: 'signer1@documenso.com', + name: 'Signer 1', + token: 'SIGNING_TOKEN', + signingOrder: 2, + role: RecipientRole.SIGNER, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: null, + rejectionReason: null, + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + }, + ], + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + if (event === WebhookTriggerEvents.DOCUMENT_OPENED) { + return { + event, + payload: { + ...basePayload, + status: DocumentStatus.PENDING, + recipients: [ + { + ...basePayload.recipients[0], + email: 'signer2@documenso.com', + name: 'Signer 2', + role: RecipientRole.VIEWER, + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: null, + signingOrder: 1, + rejectionReason: null, + signingStatus: SigningStatus.NOT_SIGNED, + }, + ], + Recipient: [ + { + ...basePayload.Recipient[0], + email: 'signer2@documenso.com', + name: 'Signer 2', + role: RecipientRole.VIEWER, + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: null, + signingOrder: 1, + rejectionReason: null, + signingStatus: SigningStatus.NOT_SIGNED, + }, + ], + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + if (event === WebhookTriggerEvents.DOCUMENT_SIGNED) { + return { + event, + payload: { + ...basePayload, + status: DocumentStatus.COMPLETED, + completedAt: now, + recipients: [ + { + ...basePayload.recipients[0], + id: 51, + email: 'signer1@documenso.com', + name: 'Signer 1', + token: 'SIGNING_TOKEN', + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signingOrder: 1, + rejectionReason: null, + }, + ], + Recipient: [ + { + ...basePayload.Recipient[0], + id: 51, + email: 'signer1@documenso.com', + name: 'Signer 1', + token: 'SIGNING_TOKEN', + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signingOrder: 1, + rejectionReason: null, + }, + ], + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + if (event === WebhookTriggerEvents.DOCUMENT_COMPLETED) { + return { + event, + payload: { + ...basePayload, + status: DocumentStatus.COMPLETED, + completedAt: now, + recipients: [ + { + id: 50, + documentId: 10, + templateId: null, + email: 'signer2@documenso.com', + name: 'Signer 2', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + signingOrder: 1, + rejectionReason: null, + role: RecipientRole.VIEWER, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + { + id: 51, + documentId: 10, + templateId: null, + email: 'signer1@documenso.com', + name: 'Signer 1', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + signingOrder: 2, + rejectionReason: null, + role: RecipientRole.SIGNER, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + ], + Recipient: [ + { + id: 50, + documentId: 10, + templateId: null, + email: 'signer2@documenso.com', + name: 'Signer 2', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + signingOrder: 1, + rejectionReason: null, + role: RecipientRole.VIEWER, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + { + id: 51, + documentId: 10, + templateId: null, + email: 'signer1@documenso.com', + name: 'Signer 1', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + signingOrder: 2, + rejectionReason: null, + role: RecipientRole.SIGNER, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + ], + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + if (event === WebhookTriggerEvents.DOCUMENT_REJECTED) { + return { + event, + payload: { + ...basePayload, + status: DocumentStatus.PENDING, + recipients: [ + { + ...basePayload.recipients[0], + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + rejectionReason: 'I do not agree with the terms', + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.REJECTED, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signingOrder: 1, + }, + ], + Recipient: [ + { + ...basePayload.Recipient[0], + signedAt: now, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + rejectionReason: 'I do not agree with the terms', + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.REJECTED, + sendStatus: SendStatus.SENT, + documentDeletedAt: null, + expired: null, + signingOrder: 1, + }, + ], + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + if (event === WebhookTriggerEvents.DOCUMENT_CANCELLED) { + return { + event, + payload: { + ...basePayload, + id: 7, + externalId: null, + userId: 3, + status: DocumentStatus.PENDING, + documentDataId: 'cm6exvn93006hi02ru90a265a', + documentMeta: { + ...basePayload.documentMeta, + id: 'cm6exvn96006ji02rqvzjvwoy', + subject: '', + message: '', + timezone: 'Etc/UTC', + dateFormat: 'yyyy-MM-dd hh:mm a', + redirectUrl: '', + emailSettings: { + documentDeleted: true, + documentPending: true, + recipientSigned: true, + recipientRemoved: true, + documentCompleted: true, + ownerDocumentCompleted: true, + recipientSigningRequest: true, + }, + }, + recipients: [ + { + id: 7, + documentId: 7, + templateId: null, + email: 'signer1@documenso.com', + name: 'Signer 1', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + signingOrder: 1, + rejectionReason: null, + role: RecipientRole.SIGNER, + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.SENT, + }, + ], + Recipient: [ + { + id: 7, + documentId: 7, + templateId: null, + email: 'signer@documenso.com', + name: 'Signer', + token: 'SIGNING_TOKEN', + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: { + accessAuth: null, + actionAuth: null, + }, + signingOrder: 1, + rejectionReason: null, + role: RecipientRole.SIGNER, + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.SENT, + }, + ], + }, + createdAt: now.toISOString(), + webhookEndpoint: webhookUrl, + }; + } + + throw new Error(`Unsupported event type: ${event}`); +}; diff --git a/packages/lib/types/webhook-payload.ts b/packages/lib/types/webhook-payload.ts index a4a2da8f6..ffd6c9961 100644 --- a/packages/lib/types/webhook-payload.ts +++ b/packages/lib/types/webhook-payload.ts @@ -1,4 +1,4 @@ -import type { Document, DocumentMeta, Recipient } from '@prisma/client'; +import type { Document, DocumentMeta, Recipient, WebhookTriggerEvents } from '@prisma/client'; import { DocumentDistributionMethod, DocumentSigningOrder, @@ -87,6 +87,13 @@ export const ZWebhookDocumentSchema = z.object({ export type TWebhookRecipient = z.infer; export type TWebhookDocument = z.infer; +export type WebhookPayload = { + event: WebhookTriggerEvents; + payload: TWebhookDocument; + createdAt: string; + webhookEndpoint: string; +}; + export const mapDocumentToWebhookDocumentPayload = ( document: Document & { recipients: Recipient[]; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index ce06e4e43..f6edb25e3 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -3,6 +3,7 @@ import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-we import { editWebhook } from '@documenso/lib/server-only/webhooks/edit-webhook'; import { getWebhookById } from '@documenso/lib/server-only/webhooks/get-webhook-by-id'; import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-team-id'; +import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook'; import { authenticatedProcedure, router } from '../trpc'; import { @@ -11,6 +12,7 @@ import { ZEditWebhookRequestSchema, ZGetTeamWebhooksRequestSchema, ZGetWebhookByIdRequestSchema, + ZTriggerTestWebhookRequestSchema, } from './schema'; export const webhookRouter = router({ @@ -106,4 +108,25 @@ export const webhookRouter = router({ teamId, }); }), + + testWebhook: authenticatedProcedure + .input(ZTriggerTestWebhookRequestSchema) + .mutation(async ({ input, ctx }) => { + const { id, event, teamId } = input; + + ctx.logger.info({ + input: { + id, + event, + teamId, + }, + }); + + return await triggerTestWebhook({ + id, + event, + userId: ctx.user.id, + teamId, + }); + }), }); diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index d6b411aaa..9ed84e3cf 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -38,3 +38,11 @@ export const ZDeleteWebhookRequestSchema = z.object({ }); export type TDeleteWebhookRequestSchema = z.infer; + +export const ZTriggerTestWebhookRequestSchema = z.object({ + id: z.string(), + event: z.nativeEnum(WebhookTriggerEvents), + teamId: z.number(), +}); + +export type TTriggerTestWebhookRequestSchema = z.infer;