diff --git a/apps/marketing/content/blog/launch-week-2-day-2.mdx b/apps/marketing/content/blog/launch-week-2-day-2.mdx
new file mode 100644
index 000000000..3a67977ec
--- /dev/null
+++ b/apps/marketing/content/blog/launch-week-2-day-2.mdx
@@ -0,0 +1,76 @@
+---
+title: Launch Week II - Day 2 - Templates
+description: Templates help you prepare regular documents faster. And you can share them with your team!
+authorName: 'Timur Ercan'
+authorImage: '/blog/blog-author-timur.jpeg'
+authorRole: 'Co-Founder'
+date: 2024-02-27
+tags:
+ - Launch Week
+ - Templates
+---
+
+
+
+> TLDR; You can now reuse documents via templates. More field types coming soon as well.
+
+## Introducing Templates
+
+It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
+We are launching templates for Documenso! Templates are an easy way to reuse documents you send out often with just a few clicks. With templates, you can:
+
+
+
+
+
+ Quickly fill out recipients, when creating from a template
+
+
+
+- Save often-uploaded documents for reuse
+- Pre-define fields, so you just have to send the document
+- Quickly fill out recipients and roles for new documents
+- Share templates with your team to make working together even easier
+
+
+
+
+
+ POV: You are a diligent german and create custom receipts with Documenso
+
+
+
+## Pricing
+
+Templates are **included in all Documenso Plans!** That includes our free tier: The limit of 5 documents per month still applies, but you are free to reach it with less friction using templates. Sharing templates with other users is only possible with the teams plan. If you want to share templates with people not in your team, we might have something coming up later this week 👀
+
+## What's Next for Templates
+
+We have a lot of great stuff coming up for templates as well:
+
+- More Field Types are in the pipeline
+- Sharing Templates Externally 👀
+
+Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
+
+> 🚨 We need you help to help us to make this the biggest launch week yet: Support us on Twitter or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
+
+Best from Hamburg\
+Timur
diff --git a/apps/marketing/content/blog/launch-week-2-day-3.mdx b/apps/marketing/content/blog/launch-week-2-day-3.mdx
new file mode 100644
index 000000000..6ea0db9b9
--- /dev/null
+++ b/apps/marketing/content/blog/launch-week-2-day-3.mdx
@@ -0,0 +1,53 @@
+---
+title: Launch Week II - Day 3 - API
+description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
+authorName: 'Timur Ercan'
+authorImage: '/blog/blog-author-timur.jpeg'
+authorRole: 'Co-Founder'
+date: 2024-02-28
+tags:
+ - Launch Week
+ - API
+---
+
+
+
+> TLDR; The public API is now availible for all plans.
+
+## Introducing the public Documenso API
+
+Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
+
+- Get Documents (Individual or all Accessible)
+- Upload Documents
+- Delete Documents
+- Create Documents from Templates
+- Trigger Sending Documents for Singing
+
+You can check out the detailed API documentation here:
+
+> API DOCUMENTATION: [https://app.documenso.com/api/v1/openapi](https://app.documenso.com/api/v1/openapi)
+
+## Pricing
+
+We are building Documenso to be an open and extendable platform; therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
+
+> Try the API here for free: [https://documen.so/api](https://documen.so/api)
+
+## What's next for the API
+
+You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
+
+Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
+
+> 🚨 We need you help to help us to make this the biggest launch week yet: Support us on Twitter or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
+
+Best from Hamburg\
+Timur
diff --git a/apps/marketing/public/blog/quickfill.png b/apps/marketing/public/blog/quickfill.png
new file mode 100644
index 000000000..17765b643
Binary files /dev/null and b/apps/marketing/public/blog/quickfill.png differ
diff --git a/apps/marketing/public/blog/template.png b/apps/marketing/public/blog/template.png
new file mode 100644
index 000000000..74dd48de9
Binary files /dev/null and b/apps/marketing/public/blog/template.png differ
diff --git a/apps/web/src/app/(dashboard)/settings/tokens/page.tsx b/apps/web/src/app/(dashboard)/settings/tokens/page.tsx
index 8951098c4..a62775522 100644
--- a/apps/web/src/app/(dashboard)/settings/tokens/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/tokens/page.tsx
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,7 +19,15 @@ export default async function ApiTokensPage() {
API Tokens
- On this page, you can create new API tokens and manage the existing ones.
+ On this page, you can create new API tokens and manage the existing ones.
+ You can view our swagger docs{' '}
+
+ here
+
diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
new file mode 100644
index 000000000..53ec24827
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
new file mode 100644
index 000000000..01196544d
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
@@ -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 (
+
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {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{' '}
+
+
+
+
+
+
+ Edit
+
+
+ Delete
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx
index 2b775d32c..eedae29d1 100644
--- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -29,7 +30,15 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
API Tokens
- On this page, you can create new API tokens and manage the existing ones.
+ On this page, you can create new API tokens and manage the existing ones.
+ You can view our swagger docs{' '}
+
+ here
+
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx
new file mode 100644
index 000000000..cc7261bda
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx
new file mode 100644
index 000000000..054664624
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx
@@ -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 (
+
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {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{' '}
+
+
+
+
+
+
+ Edit
+
+
+ Delete
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
index e87c47b67..6109d1f3d 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
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 { cn } from '@documenso/ui/lib/utils';
@@ -77,6 +77,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+
+
+
+ Webhooks
+
+
+
{isBillingEnabled && (
{
+
+
+
+ Webhooks
+
+
+
{isBillingEnabled && (
;
+
+export type CreateWebhookDialogProps = {
+ trigger?: React.ReactNode;
+} & Omit;
+
+export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const team = useOptionalCurrentTeam();
+
+ const [open, setOpen] = useState(false);
+
+ const form = useForm({
+ 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 (
+ !form.formState.isSubmitting && setOpen(value)}
+ {...props}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? Create Webhook }
+
+
+
+
+ Create webhook
+ On this page, you can create a new webhook.
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx
new file mode 100644
index 000000000..e65ae78b8
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx
@@ -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;
+ 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;
+
+ const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
+
+ const form = useForm({
+ 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 (
+ !form.formState.isSubmitting && setOpen(value)}>
+
+ {children ?? (
+
+ Delete
+
+ )}
+
+
+
+
+ Delete Webhook
+
+
+ Please note that this action is irreversible. Once confirmed, your webhook will be
+ permanently deleted.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx
new file mode 100644
index 000000000..5636f1931
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx
@@ -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([]);
+
+ 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 (
+
+
+
+ {selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
+
+
+
+
+
+ toFriendlyWebhookEventName(v)).join(', '),
+ 15,
+ )}
+ />
+ No value found.
+
+ {allEvents.map((value: string, i: number) => (
+ handleSelect(value)}>
+
+ {toFriendlyWebhookEventName(value)}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
index 20fe8cb2e..6964b2cee 100644
--- a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
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 { cn } from '@documenso/ui/lib/utils';
@@ -22,6 +22,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
+ const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@@ -59,6 +60,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+
+
+
+ Webhooks
+
+
+
{IS_BILLING_ENABLED() && (
{
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
+ const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@@ -67,6 +68,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
+
+
+
+ Webhooks
+
+
+
{IS_BILLING_ENABLED() && (
(null);
-export const useCurrentTeam = (): Team | null => {
+export const useCurrentTeam = () => {
const context = useContext(TeamContext);
if (!context) {
@@ -22,7 +22,7 @@ export const useCurrentTeam = (): Team | null => {
return context;
};
-export const useOptionalCurrentTeam = (): Team | null => {
+export const useOptionalCurrentTeam = () => {
return useContext(TeamContext);
};
diff --git a/packages/lib/server-only/crypto/sign.ts b/packages/lib/server-only/crypto/sign.ts
new file mode 100644
index 000000000..18c111c7b
--- /dev/null
+++ b/packages/lib/server-only/crypto/sign.ts
@@ -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;
+};
diff --git a/packages/lib/server-only/crypto/verify.ts b/packages/lib/server-only/crypto/verify.ts
new file mode 100644
index 000000000..7658e8b5e
--- /dev/null
+++ b/packages/lib/server-only/crypto/verify.ts
@@ -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;
+};
diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts
index b0e7e024f..5f58c5183 100644
--- a/packages/lib/server-only/document/complete-document-with-token.ts
+++ b/packages/lib/server-only/document/complete-document-with-token.ts
@@ -5,7 +5,9 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
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 { sendPendingEmail } from './send-pending-email';
@@ -15,14 +17,8 @@ export type CompleteDocumentWithTokenOptions = {
requestMetadata?: RequestMetadata;
};
-export const completeDocumentWithToken = async ({
- token,
- documentId,
- requestMetadata,
-}: CompleteDocumentWithTokenOptions) => {
- 'use server';
-
- const document = await prisma.document.findFirstOrThrow({
+const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
+ return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
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) {
throw new Error(`Document ${document.id} has already been completed`);
@@ -124,4 +130,13 @@ export const completeDocumentWithToken = async ({
if (documents.count > 0) {
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,
+ });
};
diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts
index 7243652f0..ce1f16670 100644
--- a/packages/lib/server-only/document/create-document.ts
+++ b/packages/lib/server-only/document/create-document.ts
@@ -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 { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
+import { WebhookTriggerEvents } from '@documenso/prisma/client';
+
+import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
@@ -63,6 +66,13 @@ export const createDocument = async ({
}),
});
+ await triggerWebhook({
+ event: WebhookTriggerEvents.DOCUMENT_CREATED,
+ data: document,
+ userId,
+ teamId,
+ });
+
return document;
});
};
diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts
index 09832db7d..8f39e3d25 100644
--- a/packages/lib/server-only/document/seal-document.ts
+++ b/packages/lib/server-only/document/seal-document.ts
@@ -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 { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
+import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
+import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
@@ -36,6 +38,7 @@ export const sealDocument = async ({
},
include: {
documentData: true,
+ Recipient: true,
},
});
@@ -134,4 +137,11 @@ export const sealDocument = async ({
if (sendEmail) {
await sendCompletedEmail({ documentId, requestMetadata });
}
+
+ await triggerWebhook({
+ event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
+ data: document,
+ userId: document.userId,
+ teamId: document.teamId ?? undefined,
+ });
};
diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx
index 8bb62b6dd..7c928f9a9 100644
--- a/packages/lib/server-only/document/send-document.tsx
+++ b/packages/lib/server-only/document/send-document.tsx
@@ -10,12 +10,14 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
+import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
+import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = {
documentId: number;
@@ -180,8 +182,18 @@ export const sendDocument = async ({
data: {
status: DocumentStatus.PENDING,
},
+ include: {
+ Recipient: true,
+ },
});
});
+ await triggerWebhook({
+ event: WebhookTriggerEvents.DOCUMENT_SENT,
+ data: updatedDocument,
+ userId,
+ teamId,
+ });
+
return updatedDocument;
};
diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts
index 452da1460..9722b4fbf 100644
--- a/packages/lib/server-only/document/viewed-document.ts
+++ b/packages/lib/server-only/document/viewed-document.ts
@@ -3,6 +3,10 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
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 = {
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,
+ });
};
diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts
new file mode 100644
index 000000000..5fe50f336
--- /dev/null
+++ b/packages/lib/server-only/public-api/get-user-by-token.ts
@@ -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;
+};
diff --git a/packages/lib/server-only/public-api/test-credentials.ts b/packages/lib/server-only/public-api/test-credentials.ts
new file mode 100644
index 000000000..2e20d79c4
--- /dev/null
+++ b/packages/lib/server-only/public-api/test-credentials.ts
@@ -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',
+ });
+ }
+};
diff --git a/packages/lib/server-only/webhooks/create-webhook.ts b/packages/lib/server-only/webhooks/create-webhook.ts
new file mode 100644
index 000000000..0eff215af
--- /dev/null
+++ b/packages/lib/server-only/webhooks/create-webhook.ts
@@ -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,
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/delete-webhook-by-id.ts b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts
new file mode 100644
index 000000000..9af93bc50
--- /dev/null
+++ b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts
@@ -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,
+ }),
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/edit-webhook.ts b/packages/lib/server-only/webhooks/edit-webhook.ts
new file mode 100644
index 000000000..e582ebebe
--- /dev/null
+++ b/packages/lib/server-only/webhooks/edit-webhook.ts
@@ -0,0 +1,36 @@
+import type { Prisma } from '@prisma/client';
+
+import { prisma } from '@documenso/prisma';
+
+export type EditWebhookOptions = {
+ id: string;
+ data: Omit;
+ 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,
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts b/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts
new file mode 100644
index 000000000..f2dac459b
--- /dev/null
+++ b/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts
@@ -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,
+ }),
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/get-webhook-by-id.ts b/packages/lib/server-only/webhooks/get-webhook-by-id.ts
new file mode 100644
index 000000000..fe2ff62ff
--- /dev/null
+++ b/packages/lib/server-only/webhooks/get-webhook-by-id.ts
@@ -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,
+ }),
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts b/packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts
new file mode 100644
index 000000000..82737a46d
--- /dev/null
+++ b/packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts
@@ -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',
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts
new file mode 100644
index 000000000..121fc670d
--- /dev/null
+++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts
@@ -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',
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/trigger/execute-webhook.ts b/packages/lib/server-only/webhooks/trigger/execute-webhook.ts
new file mode 100644
index 000000000..cfc828a7f
--- /dev/null
+++ b/packages/lib/server-only/webhooks/trigger/execute-webhook.ts
@@ -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,
+ },
+ });
+};
diff --git a/packages/lib/server-only/webhooks/trigger/handler.ts b/packages/lib/server-only/webhooks/trigger/handler.ts
new file mode 100644
index 000000000..4e705efea
--- /dev/null
+++ b/packages/lib/server-only/webhooks/trigger/handler.ts
@@ -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,
+) => {
+ 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' });
+};
diff --git a/packages/lib/server-only/webhooks/trigger/schema.ts b/packages/lib/server-only/webhooks/trigger/schema.ts
new file mode 100644
index 000000000..ee6d0e48d
--- /dev/null
+++ b/packages/lib/server-only/webhooks/trigger/schema.ts
@@ -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;
diff --git a/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts b/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
new file mode 100644
index 000000000..d43d227ea
--- /dev/null
+++ b/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts
@@ -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;
+ 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`);
+ }
+};
diff --git a/packages/lib/server-only/webhooks/zapier/list-documents.ts b/packages/lib/server-only/webhooks/zapier/list-documents.ts
new file mode 100644
index 000000000..56649fac8
--- /dev/null
+++ b/packages/lib/server-only/webhooks/zapier/list-documents.ts
@@ -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',
+ });
+ }
+};
diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts
new file mode 100644
index 000000000..90c68e063
--- /dev/null
+++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts
@@ -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',
+ });
+ }
+};
diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts
new file mode 100644
index 000000000..07fa75e11
--- /dev/null
+++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts
@@ -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',
+ });
+ }
+};
diff --git a/packages/lib/server-only/webhooks/zapier/validateApiToken.ts b/packages/lib/server-only/webhooks/zapier/validateApiToken.ts
new file mode 100644
index 000000000..45e2b7522
--- /dev/null
+++ b/packages/lib/server-only/webhooks/zapier/validateApiToken.ts
@@ -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`);
+ }
+};
diff --git a/packages/lib/universal/webhook/to-friendly-webhook-event-name.ts b/packages/lib/universal/webhook/to-friendly-webhook-event-name.ts
new file mode 100644
index 000000000..5af3a2782
--- /dev/null
+++ b/packages/lib/universal/webhook/to-friendly-webhook-event-name.ts
@@ -0,0 +1,3 @@
+export const toFriendlyWebhookEventName = (eventName: string) => {
+ return eventName.replace(/_/g, '.').toLowerCase();
+};
diff --git a/packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql b/packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql
new file mode 100644
index 000000000..7bf4e190f
--- /dev/null
+++ b/packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql
@@ -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;
diff --git a/packages/prisma/migrations/20240224085633_extend_webhook_trigger_events/migration.sql b/packages/prisma/migrations/20240224085633_extend_webhook_trigger_events/migration.sql
new file mode 100644
index 000000000..8733b4c9e
--- /dev/null
+++ b/packages/prisma/migrations/20240224085633_extend_webhook_trigger_events/migration.sql
@@ -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';
diff --git a/packages/prisma/migrations/20240227003622_migrate_to_cuids_for_webhooks/migration.sql b/packages/prisma/migrations/20240227003622_migrate_to_cuids_for_webhooks/migration.sql
new file mode 100644
index 000000000..cd8fd9589
--- /dev/null
+++ b/packages/prisma/migrations/20240227003622_migrate_to_cuids_for_webhooks/migration.sql
@@ -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";
diff --git a/packages/prisma/migrations/20240227015420_add_webhook_call_table/migration.sql b/packages/prisma/migrations/20240227015420_add_webhook_call_table/migration.sql
new file mode 100644
index 000000000..5abac6d6a
--- /dev/null
+++ b/packages/prisma/migrations/20240227015420_add_webhook_call_table/migration.sql
@@ -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;
diff --git a/packages/prisma/migrations/20240227023747_add_team_webhooks/migration.sql b/packages/prisma/migrations/20240227023747_add_team_webhooks/migration.sql
new file mode 100644
index 000000000..62e556228
--- /dev/null
+++ b/packages/prisma/migrations/20240227023747_add_team_webhooks/migration.sql
@@ -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;
diff --git a/packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql b/packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql
new file mode 100644
index 000000000..a56b5750a
--- /dev/null
+++ b/packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql
@@ -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;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 75dd9d1a5..b1bf9f985 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -50,6 +50,7 @@ model User {
ApiToken ApiToken[]
Template Template[]
securityAuditLogs UserSecurityAuditLog[]
+ Webhooks Webhook[]
siteSettings SiteSettings[]
@@index([email])
@@ -105,6 +106,48 @@ model VerificationToken {
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 {
SHA512
}
@@ -386,6 +429,7 @@ model Team {
document Document[]
templates Template[]
ApiToken ApiToken[]
+ Webhook Webhook[]
}
model TeamPending {
diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs
index 03c358dc3..81706fd37 100644
--- a/packages/tailwind-config/index.cjs
+++ b/packages/tailwind-config/index.cjs
@@ -11,6 +11,9 @@ module.exports = {
sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'],
},
+ zIndex: {
+ 9999: '9999',
+ },
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts
index 72fe0b2be..16f79f712 100644
--- a/packages/trpc/server/router.ts
+++ b/packages/trpc/server/router.ts
@@ -12,6 +12,7 @@ import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
+import { webhookRouter } from './webhook-router/router';
export const appRouter = router({
auth: authRouter,
@@ -26,6 +27,7 @@ export const appRouter = router({
singleplayer: singleplayerRouter,
team: teamRouter,
template: templateRouter,
+ webhook: webhookRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter,
});
diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts
new file mode 100644
index 000000000..08b1b9bce
--- /dev/null
+++ b/packages/trpc/server/webhook-router/router.ts
@@ -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.',
+ });
+ }
+ }),
+});
diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts
new file mode 100644
index 000000000..fe153ba1f
--- /dev/null
+++ b/packages/trpc/server/webhook-router/schema.ts
@@ -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;
+
+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;
+
+export const ZGetWebhookByIdQuerySchema = z.object({
+ id: z.string(),
+ teamId: z.number().optional(),
+});
+
+export type TGetWebhookByIdQuerySchema = z.infer;
+
+export const ZEditWebhookMutationSchema = ZCreateWebhookMutationSchema.extend({
+ id: z.string(),
+});
+
+export type TEditWebhookMutationSchema = z.infer;
+
+export const ZDeleteWebhookMutationSchema = z.object({
+ id: z.string(),
+ teamId: z.number().optional(),
+});
+
+export type TDeleteWebhookMutationSchema = z.infer;
diff --git a/packages/ui/primitives/badge.tsx b/packages/ui/primitives/badge.tsx
index 3569a4a7e..57418dab6 100644
--- a/packages/ui/primitives/badge.tsx
+++ b/packages/ui/primitives/badge.tsx
@@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
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: {
variant: {
@@ -21,9 +21,15 @@ const badgeVariants = cva(
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',
},
+ 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: {
variant: 'default',
+ size: 'default',
},
},
);
@@ -32,8 +38,8 @@ export interface BadgeProps
extends React.HTMLAttributes,
VariantProps {}
-function Badge({ className, variant, ...props }: BadgeProps) {
- return
;
+function Badge({ className, variant, size, ...props }: BadgeProps) {
+ return
;
}
export { Badge, badgeVariants };