feat: implement webhooks (#913)

This commit is contained in:
Lucas Smith
2024-02-28 18:31:48 +11:00
committed by GitHub
55 changed files with 2180 additions and 27 deletions

View File

@ -0,0 +1,201 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
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 { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookPageOptions = {
params: {
id: string;
};
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { toast } = useToast();
const router = useRouter();
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: params.id,
},
{ enabled: !!params.id },
);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
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,
...data,
});
toast({
title: 'Webhook updated',
description: 'The webhook has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
toast({
title: 'Failed to update webhook',
description: 'We encountered an error while updating the webhook. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div>
<SettingsHeader
title="Edit webhook"
subtitle="On this page, you can edit the webhook and its settings."
/>
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,101 @@
'use client';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
export default function WebhookPage() {
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
return (
<div>
<SettingsHeader
title="Webhooks"
subtitle="On this page, you can create new Webhooks and manage the existing ones."
>
<CreateWebhookDialog />
</SettingsHeader>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
{webhooks && webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
You have no webhooks yet. Your webhooks will be shown here once you create them.
</p>
</div>
)}
{webhooks && webhooks.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{webhooks?.map((webhook) => (
<div
key={webhook.id}
className={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-4">
<h5
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
title={webhook.webhookUrl}
>
{webhook.webhookUrl}
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
</DeleteWebhookDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,206 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
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 { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookPageOptions = {
params: {
id: string;
};
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { toast } = useToast();
const router = useRouter();
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<TEditWebhookFormSchema>({
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: 'Webhook updated',
description: 'The webhook has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
toast({
title: 'Failed to update webhook',
description: 'We encountered an error while updating the webhook. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div>
<SettingsHeader
title="Edit webhook"
subtitle="On this page, you can edit the webhook and its settings."
/>
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,106 @@
'use client';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
import { useCurrentTeam } from '~/providers/team';
export default function WebhookPage() {
const team = useCurrentTeam();
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
teamId: team.id,
});
return (
<div>
<SettingsHeader
title="Webhooks"
subtitle="On this page, you can create new Webhooks and manage the existing ones."
>
<CreateWebhookDialog />
</SettingsHeader>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
{webhooks && webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
You have no webhooks yet. Your webhooks will be shown here once you create them.
</p>
</div>
)}
{webhooks && webhooks.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{webhooks?.map((webhook) => (
<div
key={webhook.id}
className={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-2">
<h5
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
title={webhook.webhookUrl}
>
{webhook.webhookUrl}
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link href={`/t/${team.url}/settings/webhooks/${webhook.id}`}>Edit</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
</DeleteWebhookDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users } from 'lucide-react'; import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -77,6 +77,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
{isBillingEnabled && ( {isBillingEnabled && (
<Link href="/settings/billing"> <Link href="/settings/billing">
<Button <Button

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users } from 'lucide-react'; import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -80,6 +80,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
{isBillingEnabled && ( {isBillingEnabled && (
<Link href="/settings/billing"> <Link href="/settings/billing">
<Button <Button

View File

@ -0,0 +1,232 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
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 { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
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 { useOptionalCurrentTeam } from '~/providers/team';
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type CreateWebhookDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
const form = useForm<TCreateWebhookFormSchema>({
resolver: zodResolver(ZCreateWebhookFormSchema),
values: {
webhookUrl: '',
eventTriggers: [],
secret: '',
enabled: true,
},
});
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
const onSubmit = async ({
enabled,
eventTriggers,
secret,
webhookUrl,
}: TCreateWebhookFormSchema) => {
try {
await createWebhook({
enabled,
eventTriggers,
secret,
webhookUrl,
teamId: team?.id,
});
setOpen(false);
toast({
title: 'Webhook created',
description: 'The webhook was successfully created.',
});
form.reset();
router.refresh();
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating the webhook. Please try again.',
variant: 'destructive',
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
{...props}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
</DialogTrigger>
<DialogContent className="max-w-lg" position="center">
<DialogHeader>
<DialogTitle>Create webhook</DialogTitle>
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput
className="bg-background"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Create
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,172 @@
'use effect';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Webhook } from '@documenso/prisma/client';
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 { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DeleteWebhookDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
children: React.ReactNode;
};
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
const deleteMessage = `delete ${webhook.webhookUrl}`;
const ZDeleteWebhookFormSchema = z.object({
webhookUrl: z.literal(deleteMessage, {
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
}),
});
type TDeleteWebhookFormSchema = z.infer<typeof ZDeleteWebhookFormSchema>;
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
const form = useForm<TDeleteWebhookFormSchema>({
resolver: zodResolver(ZDeleteWebhookFormSchema),
values: {
webhookUrl: '',
},
});
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.id, teamId: team?.id });
toast({
title: 'Webhook deleted',
duration: 5000,
description: 'The webhook has been successfully deleted.',
});
setOpen(false);
router.refresh();
} catch (error) {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 5000,
description:
'We encountered an unknown error while attempting to delete it. Please try again later.',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{children ?? (
<Button className="mr-4" variant="destructive">
Delete
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Webhook</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your webhook will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</FormLabel>
<FormControl>
<Input className="bg-background" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
I'm sure! Delete it
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { WebhookTriggerEvents } from '@prisma/client/';
import { Check, ChevronsUpDown } from 'lucide-react';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { truncateTitle } from '~/helpers/truncate-title';
type TriggerMultiSelectComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
export const TriggerMultiSelectCombobox = ({
listValues,
onChange,
}: TriggerMultiSelectComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const triggerEvents = Object.values(WebhookTriggerEvents);
useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className="w-[200px] justify-between"
>
{selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
<Command>
<CommandInput
placeholder={truncateTitle(
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
15,
)}
/>
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allEvents.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{toFriendlyWebhookEventName(value)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation'; import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Settings, Users } from 'lucide-react'; import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -22,6 +22,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`; const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`; const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`; const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`; const billingPath = `/t/${teamUrl}/settings/billing`;
return ( return (
@ -59,6 +60,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href={webhooksPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(webhooksPath) && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
{IS_BILLING_ENABLED() && ( {IS_BILLING_ENABLED() && (
<Link href={billingPath}> <Link href={billingPath}>
<Button <Button

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation'; import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Key, User } from 'lucide-react'; import { Braces, CreditCard, Key, User, Webhook } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -22,6 +22,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`; const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`; const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`; const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`; const billingPath = `/t/${teamUrl}/settings/billing`;
return ( return (
@ -67,6 +68,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href={webhooksPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(webhooksPath) && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
{IS_BILLING_ENABLED() && ( {IS_BILLING_ENABLED() && (
<Link href={billingPath}> <Link href={billingPath}>
<Button <Button

View File

@ -0,0 +1,3 @@
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
export default testCredentialsHandler;

View File

@ -0,0 +1,3 @@
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
export default listDocumentsHandler;

View File

@ -0,0 +1,3 @@
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
export default subscribeHandler;

View File

@ -0,0 +1,3 @@
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
export default unsubscribeHandler;

View File

@ -0,0 +1,12 @@
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
export const config = {
maxDuration: 300,
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
export default handlerTriggerWebhooks;

View File

@ -12,7 +12,7 @@ interface TeamProviderProps {
const TeamContext = createContext<Team | null>(null); const TeamContext = createContext<Team | null>(null);
export const useCurrentTeam = (): Team | null => { export const useCurrentTeam = () => {
const context = useContext(TeamContext); const context = useContext(TeamContext);
if (!context) { if (!context) {
@ -22,7 +22,7 @@ export const useCurrentTeam = (): Team | null => {
return context; return context;
}; };
export const useOptionalCurrentTeam = (): Team | null => { export const useOptionalCurrentTeam = () => {
return useContext(TeamContext); return useContext(TeamContext);
}; };

View File

@ -0,0 +1,12 @@
import { hashString } from '../auth/hash';
import { encryptSecondaryData } from './encrypt';
export const sign = (data: unknown) => {
const stringified = JSON.stringify(data);
const hashed = hashString(stringified);
const signature = encryptSecondaryData({ data: hashed });
return signature;
};

View File

@ -0,0 +1,12 @@
import { hashString } from '../auth/hash';
import { decryptSecondaryData } from './decrypt';
export const verify = (data: unknown, signature: string) => {
const stringified = JSON.stringify(data);
const hashed = hashString(stringified);
const decrypted = decryptSecondaryData(signature);
return decrypted === hashed;
};

View File

@ -5,7 +5,9 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document'; import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email'; import { sendPendingEmail } from './send-pending-email';
@ -15,14 +17,8 @@ export type CompleteDocumentWithTokenOptions = {
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const completeDocumentWithToken = async ({ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
token, return await prisma.document.findFirstOrThrow({
documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
Recipient: { Recipient: {
@ -39,6 +35,16 @@ export const completeDocumentWithToken = async ({
}, },
}, },
}); });
};
export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
const document = await getDocument({ token, documentId });
if (document.status === DocumentStatus.COMPLETED) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`); throw new Error(`Document ${document.id} has already been completed`);
@ -124,4 +130,13 @@ export const completeDocumentWithToken = async ({
if (documents.count > 0) { if (documents.count > 0) {
await sealDocument({ documentId: document.id, requestMetadata }); await sealDocument({ documentId: document.id, requestMetadata });
} }
const updatedDocument = await getDocument({ token, documentId });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: updatedDocument,
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
}; };

View File

@ -5,6 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = { export type CreateDocumentOptions = {
title: string; title: string;
@ -63,6 +66,13 @@ export const createDocument = async ({
}), }),
}); });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document; return document;
}); });
}; };

View File

@ -9,12 +9,14 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file'; import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email'; import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = { export type SealDocumentOptions = {
@ -36,6 +38,7 @@ export const sealDocument = async ({
}, },
include: { include: {
documentData: true, documentData: true,
Recipient: true,
}, },
}); });
@ -134,4 +137,11 @@ export const sealDocument = async ({
if (sendEmail) { if (sendEmail) {
await sendCompletedEmail({ documentId, requestMetadata }); await sendCompletedEmail({ documentId, requestMetadata });
} }
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: document,
userId: document.userId,
teamId: document.teamId ?? undefined,
});
}; };

View File

@ -10,12 +10,14 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { import {
RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles'; } from '../../constants/recipient-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = { export type SendDocumentOptions = {
documentId: number; documentId: number;
@ -180,8 +182,18 @@ export const sendDocument = async ({
data: { data: {
status: DocumentStatus.PENDING, status: DocumentStatus.PENDING,
}, },
include: {
Recipient: true,
},
}); });
}); });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT,
data: updatedDocument,
userId,
teamId,
});
return updatedDocument; return updatedDocument;
}; };

View File

@ -3,6 +3,10 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client'; import { ReadStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = { export type ViewedDocumentOptions = {
token: string; token: string;
@ -51,4 +55,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
}), }),
}); });
}); });
const document = await getDocumentAndRecipientByToken({ token });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: document,
userId: document.userId,
teamId: document.teamId ?? undefined,
});
}; };

View File

@ -0,0 +1,37 @@
import { prisma } from '@documenso/prisma';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
ApiToken: {
some: {
token: hashedToken,
},
},
},
include: {
ApiToken: true,
},
});
if (!user) {
throw new Error('Invalid token');
}
const retrievedToken = user.ApiToken.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new Error('Invalid token');
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};

View File

@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/validateApiToken';
export const testCredentialsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { authorization } = req.headers;
const result = await validateApiToken({ authorization });
return res.status(200).json({
name: result.team?.name ?? result.user.name,
});
} catch (err) {
return res.status(500).json({
message: 'Internal Server Error',
});
}
};

View File

@ -0,0 +1,44 @@
import { prisma } from '@documenso/prisma';
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
export interface CreateWebhookOptions {
webhookUrl: string;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
enabled: boolean;
userId: number;
teamId?: number;
}
export const createWebhook = async ({
webhookUrl,
eventTriggers,
secret,
enabled,
userId,
teamId,
}: CreateWebhookOptions) => {
if (teamId) {
await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await prisma.webhook.create({
data: {
webhookUrl,
eventTriggers,
secret,
enabled,
userId,
teamId,
},
});
};

View File

@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
export type DeleteWebhookByIdOptions = {
id: string;
userId: number;
teamId?: number;
};
export const deleteWebhookById = async ({ id, userId, teamId }: DeleteWebhookByIdOptions) => {
return await prisma.webhook.delete({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
};

View File

@ -0,0 +1,36 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type EditWebhookOptions = {
id: string;
data: Omit<Prisma.WebhookUpdateInput, 'id' | 'userId' | 'teamId'>;
userId: number;
teamId?: number;
};
export const editWebhook = async ({ id, data, userId, teamId }: EditWebhookOptions) => {
return await prisma.webhook.update({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
data: {
...data,
},
});
};

View File

@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
export type GetAllWebhooksByEventTriggerOptions = {
event: WebhookTriggerEvents;
userId: number;
teamId?: number;
};
export const getAllWebhooksByEventTrigger = async ({
event,
userId,
teamId,
}: GetAllWebhooksByEventTriggerOptions) => {
return prisma.webhook.findMany({
where: {
enabled: true,
eventTriggers: {
has: event,
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
};

View File

@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
export type GetWebhookByIdOptions = {
id: string;
userId: number;
teamId?: number;
};
export const getWebhookById = async ({ id, userId, teamId }: GetWebhookByIdOptions) => {
return await prisma.webhook.findFirstOrThrow({
where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
};

View File

@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
return await prisma.webhook.findMany({
where: {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -0,0 +1,12 @@
import { prisma } from '@documenso/prisma';
export const getWebhooksByUserId = async (userId: number) => {
return await prisma.webhook.findMany({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -0,0 +1,58 @@
import { prisma } from '@documenso/prisma';
import {
Prisma,
type Webhook,
WebhookCallStatus,
type WebhookTriggerEvents,
} from '@documenso/prisma/client';
export type ExecuteWebhookOptions = {
event: WebhookTriggerEvents;
webhook: Webhook;
data: unknown;
};
export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => {
const { webhookUrl: url, secret } = webhook;
console.log('Executing webhook', { event, url });
const payload = {
event,
payload: data,
createdAt: new Date().toISOString(),
webhookEndpoint: url,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
requestBody: payload as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
webhookId: webhook.id,
},
});
};

View File

@ -0,0 +1,58 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { verify } from '../../crypto/verify';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
import { executeWebhook } from './execute-webhook';
import { ZTriggerWebhookBodySchema } from './schema';
export type HandlerTriggerWebhooksResponse =
| {
success: true;
message: string;
}
| {
success: false;
error: string;
};
export const handlerTriggerWebhooks = async (
req: NextApiRequest,
res: NextApiResponse<HandlerTriggerWebhooksResponse>,
) => {
const signature = req.headers['x-webhook-signature'];
if (typeof signature !== 'string') {
console.log('Missing signature');
return res.status(400).json({ success: false, error: 'Missing signature' });
}
const valid = verify(req.body, signature);
if (!valid) {
console.log('Invalid signature');
return res.status(400).json({ success: false, error: 'Invalid signature' });
}
const result = ZTriggerWebhookBodySchema.safeParse(req.body);
if (!result.success) {
console.log('Invalid request body');
return res.status(400).json({ success: false, error: 'Invalid request body' });
}
const { event, data, userId, teamId } = result.data;
const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
await Promise.allSettled(
allWebhooks.map(async (webhook) =>
executeWebhook({
event,
webhook,
data,
}),
),
);
return res.status(200).json({ success: true, message: 'Webhooks executed successfully' });
};

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
export const ZTriggerWebhookBodySchema = z.object({
event: z.nativeEnum(WebhookTriggerEvents),
data: z.unknown(),
userId: z.number(),
teamId: z.number().optional(),
});
export type TTriggerWebhookBodySchema = z.infer<typeof ZTriggerWebhookBodySchema>;

View File

@ -0,0 +1,40 @@
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
export type TriggerWebhookOptions = {
event: WebhookTriggerEvents;
data: Record<string, unknown>;
userId: number;
teamId?: number;
};
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
try {
const body = {
event,
data,
userId,
teamId,
};
const signature = sign(body);
await Promise.race([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webhook-signature': signature,
},
body: JSON.stringify(body),
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 500);
}),
]).catch(() => null);
} catch (err) {
throw new Error(`Failed to trigger webhook`);
}
};

View File

@ -0,0 +1,67 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import type { Webhook } from '@documenso/prisma/client';
import { getWebhooksByTeamId } from '../get-webhooks-by-team-id';
import { getWebhooksByUserId } from '../get-webhooks-by-user-id';
import { validateApiToken } from './validateApiToken';
export const listDocumentsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { authorization } = req.headers;
const { user, userId, teamId } = await validateApiToken({ authorization });
let allWebhooks: Webhook[] = [];
const documents = await findDocuments({
userId: userId ?? user.id,
teamId: teamId ?? undefined,
perPage: 1,
});
const recipients = await getRecipientsForDocument({
documentId: documents.data[0].id,
userId: userId ?? user.id,
teamId: teamId ?? undefined,
});
if (userId) {
allWebhooks = await getWebhooksByUserId(userId);
}
if (teamId) {
allWebhooks = await getWebhooksByTeamId(teamId, user.id);
}
if (documents && documents.data.length > 0 && allWebhooks.length > 0 && recipients.length > 0) {
const testWebhook = {
event: allWebhooks[0].eventTriggers.toString(),
createdAt: allWebhooks[0].createdAt,
webhookEndpoint: allWebhooks[0].webhookUrl,
payload: {
id: documents.data[0].id,
userId: documents.data[0].userId,
title: documents.data[0].title,
status: documents.data[0].status,
documentDataId: documents.data[0].documentDataId,
createdAt: documents.data[0].createdAt,
updatedAt: documents.data[0].updatedAt,
completedAt: documents.data[0].completedAt,
deletedAt: documents.data[0].deletedAt,
teamId: documents.data[0].teamId,
Recipient: recipients,
},
};
return res.status(200).json([testWebhook]);
}
return res.status(200).json([]);
} catch (err) {
return res.status(500).json({
message: 'Internal Server Error',
});
}
};

View File

@ -0,0 +1,32 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { authorization } = req.headers;
const { webhookUrl, eventTrigger } = req.body;
const result = await validateApiToken({ authorization });
const createdWebhook = await prisma.webhook.create({
data: {
webhookUrl,
eventTriggers: [eventTrigger],
secret: null,
enabled: true,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
},
});
return res.status(200).json(createdWebhook);
} catch (err) {
return res.status(500).json({
message: 'Internal Server Error',
});
}
};

View File

@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { authorization } = req.headers;
const { webhookId } = req.body;
const result = await validateApiToken({ authorization });
const deletedWebhook = await prisma.webhook.delete({
where: {
id: webhookId,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
},
});
return res.status(200).json(deletedWebhook);
} catch (err) {
return res.status(500).json({
message: 'Internal Server Error',
});
}
};

View File

@ -0,0 +1,20 @@
import { getApiTokenByToken } from '../../public-api/get-api-token-by-token';
type ValidateApiTokenOptions = {
authorization: string | undefined;
};
export const validateApiToken = async ({ authorization }: ValidateApiTokenOptions) => {
try {
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new Error('Missing API token');
}
return await getApiTokenByToken({ token });
} catch (err) {
throw new Error(`Failed to validate API token`);
}
};

View File

@ -0,0 +1,3 @@
export const toFriendlyWebhookEventName = (eventName: string) => {
return eventName.replace(/_/g, '.').toLowerCase();
};

View File

@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "WebhookTriggerEvents" AS ENUM ('DOCUMENT_CREATED', 'DOCUMENT_SIGNED');
-- CreateTable
CREATE TABLE "Webhook" (
"id" SERIAL NOT NULL,
"webhookUrl" TEXT NOT NULL,
"eventTriggers" "WebhookTriggerEvents"[],
"secret" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_SENT';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_OPENED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_COMPLETED';

View File

@ -0,0 +1,12 @@
/*
Warnings:
- The primary key for the `Webhook` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- AlterTable
ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ADD CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id");
DROP SEQUENCE "Webhook_id_seq";

View File

@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "WebhookCallStatus" AS ENUM ('SUCCESS', 'FAILED');
-- CreateTable
CREATE TABLE "WebhookCall" (
"id" TEXT NOT NULL,
"status" "WebhookCallStatus" NOT NULL,
"url" TEXT NOT NULL,
"requestBody" JSONB NOT NULL,
"responseCode" INTEGER NOT NULL,
"responseHeaders" JSONB,
"responseBody" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"webhookId" TEXT NOT NULL,
CONSTRAINT "WebhookCall_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "teamId" INTEGER;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `event` to the `WebhookCall` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "WebhookCall" ADD COLUMN "event" "WebhookTriggerEvents" NOT NULL;

View File

@ -48,6 +48,7 @@ model User {
ApiToken ApiToken[] ApiToken ApiToken[]
Template Template[] Template Template[]
securityAuditLogs UserSecurityAuditLog[] securityAuditLogs UserSecurityAuditLog[]
Webhooks Webhook[]
siteSettings SiteSettings[] siteSettings SiteSettings[]
@@index([email]) @@index([email])
@ -96,6 +97,48 @@ model VerificationToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum WebhookTriggerEvents {
DOCUMENT_CREATED
DOCUMENT_SENT
DOCUMENT_OPENED
DOCUMENT_SIGNED
DOCUMENT_COMPLETED
}
model Webhook {
id String @id @default(cuid())
webhookUrl String
eventTriggers WebhookTriggerEvents[]
secret String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
WebhookCall WebhookCall[]
}
enum WebhookCallStatus {
SUCCESS
FAILED
}
model WebhookCall {
id String @id @default(cuid())
status WebhookCallStatus
url String
event WebhookTriggerEvents
requestBody Json
responseCode Int
responseHeaders Json?
responseBody Json?
createdAt DateTime @default(now())
webhookId String
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
}
enum ApiTokenAlgorithm { enum ApiTokenAlgorithm {
SHA512 SHA512
} }
@ -377,6 +420,7 @@ model Team {
document Document[] document Document[]
templates Template[] templates Template[]
ApiToken ApiToken[] ApiToken ApiToken[]
Webhook Webhook[]
} }
model TeamPending { model TeamPending {

View File

@ -11,6 +11,9 @@ module.exports = {
sans: ['var(--font-sans)', ...fontFamily.sans], sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'], signature: ['var(--font-signature)'],
}, },
zIndex: {
9999: '9999',
},
colors: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',

View File

@ -12,6 +12,7 @@ import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router'; import { templateRouter } from './template-router/router';
import { router } from './trpc'; import { router } from './trpc';
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
import { webhookRouter } from './webhook-router/router';
export const appRouter = router({ export const appRouter = router({
auth: authRouter, auth: authRouter,
@ -26,6 +27,7 @@ export const appRouter = router({
singleplayer: singleplayerRouter, singleplayer: singleplayerRouter,
team: teamRouter, team: teamRouter,
template: templateRouter, template: templateRouter,
webhook: webhookRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter, twoFactorAuthentication: twoFactorAuthenticationRouter,
}); });

View File

@ -0,0 +1,125 @@
import { TRPCError } from '@trpc/server';
import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook';
import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id';
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 { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateWebhookMutationSchema,
ZDeleteWebhookMutationSchema,
ZEditWebhookMutationSchema,
ZGetTeamWebhooksQuerySchema,
ZGetWebhookByIdQuerySchema,
} from './schema';
export const webhookRouter = router({
getWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
try {
return await getWebhooksByUserId(ctx.user.id);
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhooks. Please try again later.',
});
}
}),
getTeamWebhooks: authenticatedProcedure
.input(ZGetTeamWebhooksQuerySchema)
.query(async ({ ctx, input }) => {
const { teamId } = input;
try {
return await getWebhooksByTeamId(teamId, ctx.user.id);
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhooks. Please try again later.',
});
}
}),
getWebhookById: authenticatedProcedure
.input(ZGetWebhookByIdQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { id, teamId } = input;
return await getWebhookById({
id,
userId: ctx.user.id,
teamId,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to fetch your webhook. Please try again later.',
});
}
}),
createWebhook: authenticatedProcedure
.input(ZCreateWebhookMutationSchema)
.mutation(async ({ input, ctx }) => {
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
try {
return await createWebhook({
enabled,
secret,
webhookUrl,
eventTriggers,
teamId,
userId: ctx.user.id,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.',
});
}
}),
deleteWebhook: authenticatedProcedure
.input(ZDeleteWebhookMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id, teamId } = input;
return await deleteWebhookById({
id,
teamId,
userId: ctx.user.id,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.',
});
}
}),
editWebhook: authenticatedProcedure
.input(ZEditWebhookMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id, teamId, ...data } = input;
return await editWebhook({
id,
data,
userId: ctx.user.id,
teamId,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this webhook. Please try again later.',
});
}
}),
});

View File

@ -0,0 +1,41 @@
import { z } from 'zod';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
export const ZGetTeamWebhooksQuerySchema = z.object({
teamId: z.number(),
});
export type TGetTeamWebhooksQuerySchema = z.infer<typeof ZGetTeamWebhooksQuerySchema>;
export const ZCreateWebhookMutationSchema = z.object({
webhookUrl: z.string().url(),
eventTriggers: z
.array(z.nativeEnum(WebhookTriggerEvents))
.min(1, { message: 'At least one event trigger is required' }),
secret: z.string().nullable(),
enabled: z.boolean(),
teamId: z.number().optional(),
});
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookMutationSchema>;
export const ZGetWebhookByIdQuerySchema = z.object({
id: z.string(),
teamId: z.number().optional(),
});
export type TGetWebhookByIdQuerySchema = z.infer<typeof ZGetWebhookByIdQuerySchema>;
export const ZEditWebhookMutationSchema = ZCreateWebhookMutationSchema.extend({
id: z.string(),
});
export type TEditWebhookMutationSchema = z.infer<typeof ZEditWebhookMutationSchema>;
export const ZDeleteWebhookMutationSchema = z.object({
id: z.string(),
teamId: z.number().optional(),
});
export type TDeleteWebhookMutationSchema = z.infer<typeof ZDeleteWebhookMutationSchema>;

View File

@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
const badgeVariants = cva( const badgeVariants = cva(
'inline-flex items-center rounded-md px-2 py-1.5 text-xs font-medium ring-1 ring-inset w-fit', 'inline-flex items-center rounded-md text-xs font-medium ring-1 ring-inset w-fit',
{ {
variants: { variants: {
variant: { variant: {
@ -21,9 +21,15 @@ const badgeVariants = cva(
secondary: secondary:
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30', 'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
}, },
size: {
small: 'px-1.5 py-0.5 text-xs',
default: 'px-2 py-1.5 text-xs',
large: 'px-3 py-2 text-sm',
},
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default',
}, },
}, },
); );
@ -32,8 +38,8 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, size, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />; return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants };