From edeeaa56518f9b9b5c5b4fe8f39bb64856e9558b Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:00:28 +0200 Subject: [PATCH 01/22] feat: implement webhooks --- .../(dashboard)/settings/webhooks/page.tsx | 71 ++++++++ .../settings/layout/desktop-nav.tsx | 15 +- .../settings/layout/mobile-nav.tsx | 15 +- .../webhooks/create-webhook-dialog.tsx | 3 + .../webhooks/delete-webhook-dialog.tsx | 167 ++++++++++++++++++ apps/web/src/components/forms/webhook.tsx | 0 .../migration.sql | 19 ++ packages/prisma/schema.prisma | 18 ++ 8 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/webhooks/page.tsx create mode 100644 apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx create mode 100644 apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx create mode 100644 apps/web/src/components/forms/webhook.tsx create mode 100644 packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql 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..e7445c1d9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Zap } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; + +export default function WebhookPage() { + // TODO: Fetch webhooks from the DB after implementing the backend + const webhooks = [ + { + id: 1, + secret: 'my-secret', + webhookUrl: 'https://example.com/webhook', + eventTriggers: ['document.created', 'document.signed'], + enabled: true, + userID: 1, + }, + ]; + + return ( +
+ + + + + {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.length > 0 && ( +
+ {webhooks.map((webhook) => ( +
+
+
+

Webhook URL

+

{webhook.webhookUrl}

+

Event triggers

+ {webhook.eventTriggers.map((trigger, index) => ( +

+ {trigger} +

+ ))} +
+
+
+ + + + +
+
+ ))} +
+ )} +
+ ); +} 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 c7ab61d8a..5b7a1b739 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 { CreditCard, Lock, User, Users } from 'lucide-react'; +import { 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'; @@ -48,6 +48,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + + + + + + )} + + + + + Delete Webhook + + + Please note that this action is irreversible. Once confirmed, your webhook will be + permanently deleted. + + + +
+ +
+ ( + + + Confirm by typing:{' '} + + {deleteMessage} + + + + + + + + )} + /> + + +
+ + + +
+
+
+
+ +
+ + ); +}; diff --git a/apps/web/src/components/forms/webhook.tsx b/apps/web/src/components/forms/webhook.tsx new file mode 100644 index 000000000..e69de29bb 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/schema.prisma b/packages/prisma/schema.prisma index 79dcdf6aa..f2ce12cab 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { VerificationToken VerificationToken[] Template Template[] securityAuditLogs UserSecurityAuditLog[] + Webhooks Webhook[] @@index([email]) } @@ -94,6 +95,23 @@ model VerificationToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +enum WebhookTriggerEvents { + DOCUMENT_CREATED + DOCUMENT_SIGNED +} + +model Webhook { + id Int @id @default(autoincrement()) + 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) +} + enum SubscriptionStatus { ACTIVE PAST_DUE From b3514bd0c7ada00debf38e6609ab18d23a5a62f5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:04:12 +0200 Subject: [PATCH 02/22] add new webhook dialog --- .../(dashboard)/settings/webhooks/page.tsx | 3 +- .../webhooks/create-webhook-dialog.tsx | 156 +++++++++++++++++- .../webhooks/multiselect-combobox.tsx | 83 ++++++++++ packages/tailwind-config/index.cjs | 3 + 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index e7445c1d9..9ca4b526e 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -5,6 +5,7 @@ import { Zap } from 'lucide-react'; 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'; export default function WebhookPage() { @@ -26,7 +27,7 @@ export default function WebhookPage() { title="Webhooks" subtitle="On this page, you can create new Webhooks and manage the existing ones." > - + {webhooks.length === 0 && ( diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 2924efc86..ec6d0f152 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -1,3 +1,157 @@ +'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 { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; + +import { MultiSelectCombobox } from './multiselect-combobox'; + + +export type CreateWebhookDialogProps = { + trigger?: React.ReactNode; +} & Omit; + export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { - return

test

; + const router = useRouter(); + const [open, setOpen] = useState(false); + + const form = useForm<>({ + resolver: zodResolver(), + values: { + webhookUrl: '', + eventTriggers: [], + secret: '', + enabled: true, + }, + }); + + const onSubmit = async () => { + console.log('submitted'); + } + + return ( + !form.formState.isSubmitting && setOpen(value)} + {...props} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Create webhook + On this page, you can create a new webhook. + + +
+ +
+ ( + + Webhook URL + + + + + + )} + /> + + ( + + Event triggers + + { + console.log(values); + onChange(values) + }} + /> + + + + )} + /> + + ( + + Secret + + + + + + )} + /> + + ( + + Active + + + + + + )} + /> + + +
+ + +
+
+ +
+
+ +
+
+ ); }; diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx new file mode 100644 index 000000000..269b83449 --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; + +import { WebhookTriggerEvents } from '@prisma/client/'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +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'; + +type ComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + + const triggerEvents = Object.values(WebhookTriggerEvents); + + React.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 ( + + + + + + + + No value found. + + {allEvents.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + ); +}; + +export { MultiSelectCombobox }; diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index 1564454d8..6dfa7d5c2 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))', From ddb9dd11d7824c50825d772abcb943f0a641dbc1 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:07:33 +0200 Subject: [PATCH 03/22] feat: added backend stuff --- .../webhooks/create-webhook-dialog.tsx | 69 ++++++++++++++----- .../server-only/webhooks/create-webhook.ts | 28 ++++++++ .../webhooks/get-webhooks-by-user-id.ts | 9 +++ packages/trpc/server/router.ts | 2 + packages/trpc/server/webhook-router/router.ts | 35 ++++++++++ packages/trpc/server/webhook-router/schema.ts | 14 ++++ 6 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 packages/lib/server-only/webhooks/create-webhook.ts create mode 100644 packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts create mode 100644 packages/trpc/server/webhook-router/router.ts create mode 100644 packages/trpc/server/webhook-router/schema.ts diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index ec6d0f152..7d4003fb5 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -1,16 +1,26 @@ '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 { ZCreateWebhookFormSchema } from '@documenso/trpc/server/webhook-router/schema'; import { Button } from '@documenso/ui/primitives/button'; -import { Input } from '@documenso/ui/primitives/input'; -import { Switch } from '@documenso/ui/primitives/switch'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -19,9 +29,13 @@ import { FormLabel, FormMessage, } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { MultiSelectCombobox } from './multiselect-combobox'; +type TCreateWebhookFormSchema = z.infer; export type CreateWebhookDialogProps = { trigger?: React.ReactNode; @@ -29,10 +43,11 @@ export type CreateWebhookDialogProps = { export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { const router = useRouter(); + const { toast } = useToast(); const [open, setOpen] = useState(false); - const form = useForm<>({ - resolver: zodResolver(), + const form = useForm({ + resolver: zodResolver(ZCreateWebhookFormSchema), values: { webhookUrl: '', eventTriggers: [], @@ -41,9 +56,30 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr }, }); - const onSubmit = async () => { - console.log('submitted'); - } + const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation(); + + const onSubmit = async (values: TCreateWebhookFormSchema) => { + try { + await createWebhook(values); + + 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 ( { console.log(values); - onChange(values) + onChange(values); }} /> @@ -101,28 +137,28 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr )} /> - ( Secret - + )} /> - ( Active -
-
- 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..ee352c49f --- /dev/null +++ b/packages/lib/server-only/webhooks/create-webhook.ts @@ -0,0 +1,28 @@ +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; +} + +export const createWebhook = async ({ + webhookUrl, + eventTriggers, + secret, + enabled, + userId, +}: CreateWebhookOptions) => { + return await prisma.webhook.create({ + data: { + webhookUrl, + eventTriggers, + secret, + enabled, + userId, + }, + }); +}; 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..a775ac30c --- /dev/null +++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts @@ -0,0 +1,9 @@ +import { prisma } from '@documenso/prisma'; + +export const getWebhooksByUserId = async (userId: number) => { + return await prisma.webhook.findMany({ + where: { + userId, + }, + }); +}; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index aec70fd63..571d43669 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -11,6 +11,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, @@ -24,6 +25,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..ffdd7c4bf --- /dev/null +++ b/packages/trpc/server/webhook-router/router.ts @@ -0,0 +1,35 @@ +import { TRPCError } from '@trpc/server'; + +import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook'; +import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id'; + +import { authenticatedProcedure, router } from '../trpc'; +import { ZCreateWebhookFormSchema } 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.', + }); + } + }), + createWebhook: authenticatedProcedure + .input(ZCreateWebhookFormSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createWebhook({ + ...input, + userId: ctx.user.id, + }); + } 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..feceac054 --- /dev/null +++ b/packages/trpc/server/webhook-router/schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WebhookTriggerEvents } from '@documenso/prisma/client'; + +export const ZCreateWebhookFormSchema = 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(), +}); + +export type TCreateWebhookFormSchema = z.infer; From 0209127136556074cc026fb621b513b1c258e186 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:28:18 +0200 Subject: [PATCH 04/22] feat: delete webhook functionality --- .../(dashboard)/settings/webhooks/page.tsx | 29 ++++++++++--------- .../webhooks/create-webhook-dialog.tsx | 1 - .../webhooks/delete-webhook-dialog.tsx | 2 +- .../webhooks/delete-webhook-by-id.ts | 15 ++++++++++ packages/trpc/server/webhook-router/router.ts | 19 ++++++++++++ packages/trpc/server/webhook-router/schema.ts | 6 ++++ 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 packages/lib/server-only/webhooks/delete-webhook-by-id.ts diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index 9ca4b526e..060257d72 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -1,7 +1,9 @@ 'use client'; import { Zap } from 'lucide-react'; +import { ToggleLeft, ToggleRight } from 'lucide-react'; +import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; @@ -9,17 +11,7 @@ import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; export default function WebhookPage() { - // TODO: Fetch webhooks from the DB after implementing the backend - const webhooks = [ - { - id: 1, - secret: 'my-secret', - webhookUrl: 'https://example.com/webhook', - eventTriggers: ['document.created', 'document.signed'], - enabled: true, - userID: 1, - }, - ]; + const { data: webhooks } = trpc.webhook.getWebhooks.useQuery(); return (
@@ -30,7 +22,7 @@ export default function WebhookPage() { - {webhooks.length === 0 && ( + {webhooks?.length === 0 && ( // TODO: Perhaps add some illustrations here to make the page more engaging

@@ -39,9 +31,9 @@ export default function WebhookPage() {

)} - {webhooks.length > 0 && ( + {webhooks?.length > 0 && (
- {webhooks.map((webhook) => ( + {webhooks?.map((webhook) => (
@@ -53,6 +45,15 @@ export default function WebhookPage() { {trigger}

))} + {webhook.enabled ? ( +

+ Active +

+ ) : ( +

+ Inactive +

+ )}
diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 7d4003fb5..0e24b04a7 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -127,7 +127,6 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr { - console.log(values); onChange(values); }} /> 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 index 540bcf657..8f4a4008f 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -52,7 +52,7 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr type TDeleteWebhookFormSchema = z.infer; - const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhookById.useMutation(); + const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation(); const form = useForm({ resolver: zodResolver(ZDeleteWebhookFormSchema), 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..306d0ca9c --- /dev/null +++ b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteWebhookByIdOptions = { + id: number; + userId: number; +}; + +export const deleteWebhookById = async ({ id, userId }: DeleteWebhookByIdOptions) => { + return await prisma.webhook.delete({ + where: { + id, + userId, + }, + }); +}; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index ffdd7c4bf..6598b856a 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -1,10 +1,12 @@ 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 { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id'; import { authenticatedProcedure, router } from '../trpc'; import { ZCreateWebhookFormSchema } from './schema'; +import { ZDeleteWebhookSchema } from './schema'; export const webhookRouter = router({ getWebhooks: authenticatedProcedure.query(async ({ ctx }) => { @@ -32,4 +34,21 @@ export const webhookRouter = router({ }); } }), + deleteWebhook: authenticatedProcedure + .input(ZDeleteWebhookSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + return await deleteWebhookById({ + id, + userId: ctx.user.id, + }); + } 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 index feceac054..aba409c2f 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -11,4 +11,10 @@ export const ZCreateWebhookFormSchema = z.object({ enabled: z.boolean(), }); +export const ZDeleteWebhookSchema = z.object({ + id: z.number(), +}); + export type TCreateWebhookFormSchema = z.infer; + +export type TDeleteWebhookSchema = z.infer; From 61958989b46db31efcc6fe6b6c9d711d089d26d4 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 14 Feb 2024 14:38:58 +0200 Subject: [PATCH 05/22] feat: more webhook functionality --- .../settings/webhooks/[id]/page.tsx | 162 ++++++++++++++++++ .../(dashboard)/settings/webhooks/page.tsx | 16 +- .../webhooks/multiselect-combobox.tsx | 6 +- .../lib/server-only/user/get-user-webhooks.ts | 17 ++ .../lib/server-only/webhooks/edit-webhook.ts | 21 +++ .../server-only/webhooks/get-webhook-by-id.ts | 15 ++ packages/trpc/server/webhook-router/router.ts | 48 +++++- packages/trpc/server/webhook-router/schema.ts | 16 +- 8 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx create mode 100644 packages/lib/server-only/user/get-user-webhooks.ts create mode 100644 packages/lib/server-only/webhooks/edit-webhook.ts create mode 100644 packages/lib/server-only/webhooks/get-webhook-by-id.ts 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..56a1e90a9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +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, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/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 { MultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/multiselect-combobox'; + +const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true }); + +type TEditWebhookFormSchema = z.infer; + +export type WebhookPageOptions = { + params: { + id: number; + }; +}; + +export default function WebhookPage({ params }: WebhookPageOptions) { + const { toast } = useToast(); + const router = useRouter(); + + const { data: webhook } = trpc.webhook.getWebhookById.useQuery( + { + id: Number(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: Number(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 ( +
+ +
+ +
+ ( + + Webhook URL + + + + )} + /> + ( + + Event triggers + + { + onChange(values); + }} + /> + + + + )} + /> + ( + + Secret + + + + + + )} + /> + + ( + + Active + + + + + + )} + /> +
+ +
+
+
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index 060257d72..638443bf9 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -1,5 +1,7 @@ 'use client'; +import Link from 'next/link'; + import { Zap } from 'lucide-react'; import { ToggleLeft, ToggleRight } from 'lucide-react'; @@ -22,7 +24,7 @@ export default function WebhookPage() { - {webhooks?.length === 0 && ( + {webhooks && webhooks.length === 0 && ( // TODO: Perhaps add some illustrations here to make the page more engaging

@@ -31,7 +33,7 @@ export default function WebhookPage() {

)} - {webhooks?.length > 0 && ( + {webhooks && webhooks.length > 0 && (
{webhooks?.map((webhook) => (
@@ -41,9 +43,9 @@ export default function WebhookPage() {

{webhook.webhookUrl}

Event triggers

{webhook.eventTriggers.map((trigger, index) => ( -

- {trigger} -

+ + {trigger} + ))} {webhook.enabled ? (

@@ -57,8 +59,8 @@ export default function WebhookPage() {

- diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx index 269b83449..2adbaeb7a 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx @@ -14,6 +14,8 @@ import { } from '@documenso/ui/primitives/command'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { truncateTitle } from '~/helpers/truncate-title'; + type ComboboxProps = { listValues: string[]; onChange: (_values: string[]) => void; @@ -53,13 +55,13 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { aria-expanded={isOpen} className="w-[200px] justify-between" > - {selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'} + {selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'} - + No value found. {allEvents.map((value: string, i: number) => ( diff --git a/packages/lib/server-only/user/get-user-webhooks.ts b/packages/lib/server-only/user/get-user-webhooks.ts new file mode 100644 index 000000000..26c47e0f4 --- /dev/null +++ b/packages/lib/server-only/user/get-user-webhooks.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetUserWebhooksByIdOptions { + id: number; +} + +export const getUserWebhooksById = async ({ id }: GetUserWebhooksByIdOptions) => { + return await prisma.user.findFirstOrThrow({ + where: { + id, + }, + select: { + email: true, + Webhooks: true, + }, + }); +}; 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..4177bb2bf --- /dev/null +++ b/packages/lib/server-only/webhooks/edit-webhook.ts @@ -0,0 +1,21 @@ +import type { Prisma } from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +export type EditWebhookOptions = { + id: number; + data: Prisma.WebhookUpdateInput; + userId: number; +}; + +export const editWebhook = async ({ id, data, userId }: EditWebhookOptions) => { + return await prisma.webhook.update({ + where: { + id, + userId, + }, + data: { + ...data, + }, + }); +}; 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..82dbb70ef --- /dev/null +++ b/packages/lib/server-only/webhooks/get-webhook-by-id.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type GetWebhookByIdOptions = { + id: number; + userId: number; +}; + +export const getWebhookById = async ({ id, userId }: GetWebhookByIdOptions) => { + return await prisma.webhook.findFirstOrThrow({ + where: { + id, + userId, + }, + }); +}; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index 6598b856a..aeb7e6f38 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -2,11 +2,17 @@ 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 { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id'; import { authenticatedProcedure, router } from '../trpc'; -import { ZCreateWebhookFormSchema } from './schema'; -import { ZDeleteWebhookSchema } from './schema'; +import { + ZCreateWebhookFormSchema, + ZDeleteWebhookMutationSchema, + ZEditWebhookMutationSchema, + ZGetWebhookByIdQuerySchema, +} from './schema'; export const webhookRouter = router({ getWebhooks: authenticatedProcedure.query(async ({ ctx }) => { @@ -19,6 +25,24 @@ export const webhookRouter = router({ }); } }), + getWebhookById: authenticatedProcedure + .input(ZGetWebhookByIdQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { id } = input; + + return await getWebhookById({ + id, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to fetch your webhook. Please try again later.', + }); + } + }), + createWebhook: authenticatedProcedure .input(ZCreateWebhookFormSchema) .mutation(async ({ input, ctx }) => { @@ -35,7 +59,7 @@ export const webhookRouter = router({ } }), deleteWebhook: authenticatedProcedure - .input(ZDeleteWebhookSchema) + .input(ZDeleteWebhookMutationSchema) .mutation(async ({ input, ctx }) => { try { const { id } = input; @@ -51,4 +75,22 @@ export const webhookRouter = router({ }); } }), + editWebhook: authenticatedProcedure + .input(ZEditWebhookMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + return await editWebhook({ + id, + data: input, + userId: ctx.user.id, + }); + } 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 index aba409c2f..def654a70 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -11,10 +11,22 @@ export const ZCreateWebhookFormSchema = z.object({ enabled: z.boolean(), }); -export const ZDeleteWebhookSchema = z.object({ +export const ZGetWebhookByIdQuerySchema = z.object({ + id: z.number(), +}); + +export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({ + id: z.number(), +}); + +export const ZDeleteWebhookMutationSchema = z.object({ id: z.number(), }); export type TCreateWebhookFormSchema = z.infer; -export type TDeleteWebhookSchema = z.infer; +export type TGetWebhookByIdQuerySchema = z.infer; + +export type TDeleteWebhookMutationSchema = z.infer; + +export type TEditWebhookMutationSchema = z.infer; From 019db27b1d4b040cdbacf2df791f37efbd362478 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:04:11 +0200 Subject: [PATCH 06/22] feat: trigger webhook functionality --- .../document/complete-document-with-token.ts | 27 +++++++++---- .../server-only/document/create-document.ts | 12 +++++- .../server-only/webhooks/get-all-webhooks.ts | 17 ++++++++ .../lib/universal/post-webhook-payload.ts | 39 +++++++++++++++++++ packages/lib/universal/trigger-webhook.ts | 30 ++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 packages/lib/server-only/webhooks/get-all-webhooks.ts create mode 100644 packages/lib/universal/post-webhook-payload.ts create mode 100644 packages/lib/universal/trigger-webhook.ts 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 62db516fa..b1438f0ce 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -2,7 +2,9 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import { triggerWebhook } from '../../universal/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -11,13 +13,8 @@ export type CompleteDocumentWithTokenOptions = { documentId: number; }; -export const completeDocumentWithToken = async ({ - token, - documentId, -}: CompleteDocumentWithTokenOptions) => { - 'use server'; - - const document = await prisma.document.findFirstOrThrow({ +const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => { + return await prisma.document.findFirstOrThrow({ where: { id: documentId, Recipient: { @@ -34,6 +31,15 @@ export const completeDocumentWithToken = async ({ }, }, }); +}; + +export const completeDocumentWithToken = async ({ + token, + documentId, +}: CompleteDocumentWithTokenOptions) => { + 'use server'; + + const document = await getDocument({ token, documentId }); if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); @@ -101,4 +107,11 @@ export const completeDocumentWithToken = async ({ if (documents.count > 0) { await sealDocument({ documentId: document.id }); } + + const updatedDocument = await getDocument({ token, documentId }); + + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED, + documentData: updatedDocument, + }); }; diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index 93307a7b4..82dacfba7 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -1,6 +1,9 @@ 'use server'; import { prisma } from '@documenso/prisma'; +import { WebhookTriggerEvents } from '@documenso/prisma/client'; + +import { triggerWebhook } from '../../universal/trigger-webhook'; export type CreateDocumentOptions = { title: string; @@ -29,7 +32,7 @@ export const createDocument = async ({ }); } - return await tx.document.create({ + const createdDocument = await tx.document.create({ data: { title, documentDataId, @@ -37,5 +40,12 @@ export const createDocument = async ({ teamId, }, }); + + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED, + documentData: createdDocument, + }); + + return createdDocument; }); }; diff --git a/packages/lib/server-only/webhooks/get-all-webhooks.ts b/packages/lib/server-only/webhooks/get-all-webhooks.ts new file mode 100644 index 000000000..a6c88a086 --- /dev/null +++ b/packages/lib/server-only/webhooks/get-all-webhooks.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; +import type { WebhookTriggerEvents } from '@documenso/prisma/client'; + +export type GetAllWebhooksOptions = { + eventTrigger: WebhookTriggerEvents; +}; + +export const getAllWebhooks = async ({ eventTrigger }: GetAllWebhooksOptions) => { + return prisma.webhook.findMany({ + where: { + eventTriggers: { + has: eventTrigger, + }, + enabled: true, + }, + }); +}; diff --git a/packages/lib/universal/post-webhook-payload.ts b/packages/lib/universal/post-webhook-payload.ts new file mode 100644 index 000000000..80ddea80d --- /dev/null +++ b/packages/lib/universal/post-webhook-payload.ts @@ -0,0 +1,39 @@ +import type { Document, Webhook } from '@documenso/prisma/client'; + +export type PostWebhookPayloadOptions = { + webhookData: Pick; + documentData: Document; +}; + +export const postWebhookPayload = async ({ + webhookData, + documentData, +}: PostWebhookPayloadOptions) => { + const { webhookUrl, secret } = webhookData; + + const payload = { + event: webhookData.eventTriggers.toString(), + createdAt: new Date().toISOString(), + webhookEndpoint: webhookUrl, + payload: documentData, + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Documenso-Secret': secret ?? '', + }, + }); + + if (!response.ok) { + throw new Error(`Webhook failed with the status code ${response.status}`); + } + + return { + status: response.status, + statusText: response.statusText, + message: 'Webhook sent successfully', + }; +}; diff --git a/packages/lib/universal/trigger-webhook.ts b/packages/lib/universal/trigger-webhook.ts new file mode 100644 index 000000000..025a154bc --- /dev/null +++ b/packages/lib/universal/trigger-webhook.ts @@ -0,0 +1,30 @@ +import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client'; + +import { getAllWebhooks } from '../server-only/webhooks/get-all-webhooks'; +import { postWebhookPayload } from './post-webhook-payload'; + +export type TriggerWebhookOptions = { + eventTrigger: WebhookTriggerEvents; + documentData: Document; +}; + +export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => { + try { + const allWebhooks = await getAllWebhooks({ eventTrigger }); + + const webhookPromises = allWebhooks.map((webhook) => { + const { webhookUrl, secret } = webhook; + + postWebhookPayload({ + webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] }, + documentData, + }).catch((_err) => { + throw new Error(`Failed to send webhook to ${webhookUrl}`); + }); + }); + + return Promise.all(webhookPromises); + } catch (err) { + throw new Error(`Failed to trigger webhook`); + } +}; From 7f3f6f531232619ee574734cca4b8a44c990f733 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:44:03 +0200 Subject: [PATCH 07/22] feat: hide secret field --- .../src/app/(dashboard)/settings/webhooks/[id]/page.tsx | 3 ++- .../settings/webhooks/create-webhook-dialog.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index 56a1e90a9..785536c2a 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -18,6 +18,7 @@ import { 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'; @@ -125,7 +126,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) { Secret - + diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 0e24b04a7..2d4dc733e 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -30,6 +30,7 @@ import { 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'; @@ -143,7 +144,11 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr Secret - + From cd240ae8a4e29a0471c76b832891b8c07e1a09e6 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 16 Feb 2024 13:44:28 +0200 Subject: [PATCH 08/22] chore: loading spinner --- .../src/app/(dashboard)/settings/webhooks/[id]/page.tsx | 8 +++++++- apps/web/src/app/(dashboard)/settings/webhooks/page.tsx | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index 785536c2a..b9f04790c 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -3,6 +3,7 @@ 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'; @@ -39,7 +40,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) { const { toast } = useToast(); const router = useRouter(); - const { data: webhook } = trpc.webhook.getWebhookById.useQuery( + const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( { id: Number(params.id), }, @@ -87,6 +88,11 @@ export default function WebhookPage({ params }: WebhookPageOptions) { title="Edit webhook" subtitle="On this page, you can edit the webhook and its settings." /> + {isLoading && ( +
+ +
+ )}
diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index 638443bf9..d0532c065 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { Zap } from 'lucide-react'; import { ToggleLeft, ToggleRight } from 'lucide-react'; +import { Loader } from 'lucide-react'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -13,7 +14,7 @@ import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; export default function WebhookPage() { - const { data: webhooks } = trpc.webhook.getWebhooks.useQuery(); + const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery(); return (
@@ -24,6 +25,12 @@ export default function WebhookPage() { + {isLoading && ( +
+ +
+ )} + {webhooks && webhooks.length === 0 && ( // TODO: Perhaps add some illustrations here to make the page more engaging
From 26d4bbf0103b5252d857faaacc345dea237d22cd Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 16 Feb 2024 13:58:03 +0200 Subject: [PATCH 09/22] chore: ui updates --- apps/web/src/app/(dashboard)/settings/webhooks/page.tsx | 2 +- .../(dashboard)/settings/webhooks/create-webhook-dialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index d0532c065..d36de6726 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -65,7 +65,7 @@ export default function WebhookPage() { )}
-
+
diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 2d4dc733e..c32493c4f 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -92,7 +92,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr {trigger ?? } - + Create webhook On this page, you can create a new webhook. From fab4992e13ec691cc979e6c4fb7aece91550a5de Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Sat, 24 Feb 2024 11:18:58 +0200 Subject: [PATCH 10/22] feat: add zapier support --- apps/web/src/pages/api/v1/me/index.ts | 3 ++ .../api/v1/zapier/list-documents/index.ts | 3 ++ .../pages/api/v1/zapier/subscribe/index.ts | 3 ++ .../pages/api/v1/zapier/unsubscribe/index.ts | 3 ++ .../lib/server-only/document/seal-document.ts | 7 +++ .../server-only/document/send-document.tsx | 7 +++ .../server-only/document/viewed-document.ts | 22 ++++++++ .../public-api/get-user-by-token.ts | 37 ++++++++++++++ .../public-api/test-credentials.ts | 19 +++++++ ...s => get-all-webhooks-by-event-trigger.ts} | 6 ++- .../webhooks/get-webhooks-by-user-id.ts | 3 ++ .../webhooks/zapier/list-documents.ts | 51 +++++++++++++++++++ .../server-only/webhooks/zapier/subscribe.ts | 29 +++++++++++ .../webhooks/zapier/unsubscribe.ts | 26 ++++++++++ .../webhooks/zapier/validateApiToken.ts | 16 ++++++ packages/lib/universal/trigger-webhook.ts | 4 +- .../migration.sql | 11 ++++ packages/prisma/schema.prisma | 21 ++++---- 18 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/pages/api/v1/me/index.ts create mode 100644 apps/web/src/pages/api/v1/zapier/list-documents/index.ts create mode 100644 apps/web/src/pages/api/v1/zapier/subscribe/index.ts create mode 100644 apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts create mode 100644 packages/lib/server-only/public-api/get-user-by-token.ts create mode 100644 packages/lib/server-only/public-api/test-credentials.ts rename packages/lib/server-only/webhooks/{get-all-webhooks.ts => get-all-webhooks-by-event-trigger.ts} (64%) create mode 100644 packages/lib/server-only/webhooks/zapier/list-documents.ts create mode 100644 packages/lib/server-only/webhooks/zapier/subscribe.ts create mode 100644 packages/lib/server-only/webhooks/zapier/unsubscribe.ts create mode 100644 packages/lib/server-only/webhooks/zapier/validateApiToken.ts create mode 100644 packages/prisma/migrations/20240224085633_extend_webhook_trigger_events/migration.sql diff --git a/apps/web/src/pages/api/v1/me/index.ts b/apps/web/src/pages/api/v1/me/index.ts new file mode 100644 index 000000000..a877c11d0 --- /dev/null +++ b/apps/web/src/pages/api/v1/me/index.ts @@ -0,0 +1,3 @@ +import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials'; + +export default testCredentialsHandler; diff --git a/apps/web/src/pages/api/v1/zapier/list-documents/index.ts b/apps/web/src/pages/api/v1/zapier/list-documents/index.ts new file mode 100644 index 000000000..ba2a35b43 --- /dev/null +++ b/apps/web/src/pages/api/v1/zapier/list-documents/index.ts @@ -0,0 +1,3 @@ +import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents'; + +export default listDocumentsHandler; diff --git a/apps/web/src/pages/api/v1/zapier/subscribe/index.ts b/apps/web/src/pages/api/v1/zapier/subscribe/index.ts new file mode 100644 index 000000000..6bcfe9e74 --- /dev/null +++ b/apps/web/src/pages/api/v1/zapier/subscribe/index.ts @@ -0,0 +1,3 @@ +import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe'; + +export default subscribeHandler; diff --git a/apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts b/apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts new file mode 100644 index 000000000..f93dd6af7 --- /dev/null +++ b/apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts @@ -0,0 +1,3 @@ +import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe'; + +export default unsubscribeHandler; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 09832db7d..f5dc56427 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -9,9 +9,11 @@ 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 { triggerWebhook } from '../../universal/trigger-webhook'; import { getFile } from '../../universal/upload/get-file'; import { putFile } from '../../universal/upload/put-file'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; @@ -134,4 +136,9 @@ export const sealDocument = async ({ if (sendEmail) { await sendCompletedEmail({ documentId, requestMetadata }); } + + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_COMPLETED, + documentData: document, + }); }; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index be26ffcaf..5ab7d0f24 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 '../../universal/trigger-webhook'; export type SendDocumentOptions = { documentId: number; @@ -163,5 +165,10 @@ export const sendDocument = async ({ }, }); + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_SENT, + documentData: updatedDocument, + }); + return updatedDocument; }; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 452da1460..8ad485917 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 '../../universal/trigger-webhook'; +import { getDocumentAndSenderByToken } from './get-document-by-token'; export type ViewedDocumentOptions = { token: string; @@ -51,4 +55,22 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO }), }); }); + + const document = await getDocumentAndSenderByToken({ token }); + + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_OPENED, + documentData: { + id: document.id, + userId: document.userId, + title: document.title, + status: document.status, + documentDataId: document.documentDataId, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + completedAt: document.completedAt, + deletedAt: document.deletedAt, + teamId: document.teamId, + }, + }); }; 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..02eb14cbf --- /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 user = await validateApiToken({ authorization }); + + return res.status(200).json({ + username: user.name, + email: user.email, + }); + } catch (err) { + return res.status(500).json({ + message: 'Internal Server Error', + }); + } +}; diff --git a/packages/lib/server-only/webhooks/get-all-webhooks.ts b/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts similarity index 64% rename from packages/lib/server-only/webhooks/get-all-webhooks.ts rename to packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts index a6c88a086..9ce31477a 100644 --- a/packages/lib/server-only/webhooks/get-all-webhooks.ts +++ b/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts @@ -1,11 +1,13 @@ import { prisma } from '@documenso/prisma'; import type { WebhookTriggerEvents } from '@documenso/prisma/client'; -export type GetAllWebhooksOptions = { +export type GetAllWebhooksByEventTriggerOptions = { eventTrigger: WebhookTriggerEvents; }; -export const getAllWebhooks = async ({ eventTrigger }: GetAllWebhooksOptions) => { +export const getAllWebhooksByEventTrigger = async ({ + eventTrigger, +}: GetAllWebhooksByEventTriggerOptions) => { return prisma.webhook.findMany({ where: { eventTriggers: { 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 index a775ac30c..121fc670d 100644 --- a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts +++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts @@ -5,5 +5,8 @@ export const getWebhooksByUserId = async (userId: number) => { where: { userId, }, + orderBy: { + createdAt: 'desc', + }, }); }; 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..c66a671a2 --- /dev/null +++ b/packages/lib/server-only/webhooks/zapier/list-documents.ts @@ -0,0 +1,51 @@ +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 { 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 = await validateApiToken({ authorization }); + + const documents = await findDocuments({ userId: user.id }); + const allWebhooks = await getWebhooksByUserId(user.id); + const recipients = await getRecipientsForDocument({ + documentId: documents.data[0].id, + userId: user.id, + }); + + if (documents.data.length > 0 && allWebhooks.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) { + console.error(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..6fa22ab5f --- /dev/null +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -0,0 +1,29 @@ +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 user = await validateApiToken({ authorization }); + + const createdWebhook = await prisma.webhook.create({ + data: { + webhookUrl, + eventTriggers: [eventTrigger], + secret: null, + enabled: true, + userId: user.id, + }, + }); + + 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..30ee1e25a --- /dev/null +++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts @@ -0,0 +1,26 @@ +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 user = await validateApiToken({ authorization }); + + const deletedWebhook = await prisma.webhook.delete({ + where: { + id: webhookId, + userId: user.id, + }, + }); + + 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..2a8a44777 --- /dev/null +++ b/packages/lib/server-only/webhooks/zapier/validateApiToken.ts @@ -0,0 +1,16 @@ +import { getUserByApiToken } from '../../public-api/get-user-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); + + return await getUserByApiToken({ token }); + } catch (err) { + throw new Error(`Failed to validate API token`); + } +}; diff --git a/packages/lib/universal/trigger-webhook.ts b/packages/lib/universal/trigger-webhook.ts index 025a154bc..72484d6c3 100644 --- a/packages/lib/universal/trigger-webhook.ts +++ b/packages/lib/universal/trigger-webhook.ts @@ -1,6 +1,6 @@ import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client'; -import { getAllWebhooks } from '../server-only/webhooks/get-all-webhooks'; +import { getAllWebhooksByEventTrigger } from '../server-only/webhooks/get-all-webhooks-by-event-trigger'; import { postWebhookPayload } from './post-webhook-payload'; export type TriggerWebhookOptions = { @@ -10,7 +10,7 @@ export type TriggerWebhookOptions = { export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => { try { - const allWebhooks = await getAllWebhooks({ eventTrigger }); + const allWebhooks = await getAllWebhooksByEventTrigger({ eventTrigger }); const webhookPromises = allWebhooks.map((webhook) => { const { webhookUrl, secret } = webhook; 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/schema.prisma b/packages/prisma/schema.prisma index 055479ff8..bcd42f59c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -97,7 +97,10 @@ model VerificationToken { enum WebhookTriggerEvents { DOCUMENT_CREATED + DOCUMENT_SENT + DOCUMENT_OPENED DOCUMENT_SIGNED + DOCUMENT_COMPLETED } model Webhook { @@ -228,15 +231,15 @@ model DocumentData { } model DocumentMeta { - id String @id @default(cuid()) - subject String? - message String? - timezone String? @default("Etc/UTC") @db.Text - password String? - dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text - documentId Int @unique - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - redirectUrl String? + id String @id @default(cuid()) + subject String? + message String? + timezone String? @default("Etc/UTC") @db.Text + password String? + dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text + documentId Int @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + redirectUrl String? } enum ReadStatus { From 15c22d389738dd270361f7392268414bc81c8787 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 26 Feb 2024 12:52:30 +0200 Subject: [PATCH 11/22] feat: updated the triggerWebhook function --- .../app/(dashboard)/settings/webhooks/page.tsx | 2 +- .../lib/server-only/document/seal-document.ts | 1 + .../lib/server-only/document/send-document.tsx | 3 +++ .../lib/server-only/document/viewed-document.ts | 17 +++-------------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index d36de6726..b646faffe 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -47,7 +47,7 @@ export default function WebhookPage() {

Webhook URL

-

{webhook.webhookUrl}

+

{webhook.webhookUrl}

Event triggers

{webhook.eventTriggers.map((trigger, index) => ( diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index f5dc56427..7b27e402a 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -38,6 +38,7 @@ export const sealDocument = async ({ }, include: { documentData: true, + Recipient: true, }, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 8979ff7e5..d822e4a53 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -182,6 +182,9 @@ export const sendDocument = async ({ data: { status: DocumentStatus.PENDING, }, + include: { + Recipient: true, + }, }); }); diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 8ad485917..3e895882d 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -6,7 +6,7 @@ import { ReadStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { triggerWebhook } from '../../universal/trigger-webhook'; -import { getDocumentAndSenderByToken } from './get-document-by-token'; +import { getDocumentAndRecipientByToken } from './get-document-by-token'; export type ViewedDocumentOptions = { token: string; @@ -56,21 +56,10 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO }); }); - const document = await getDocumentAndSenderByToken({ token }); + const document = await getDocumentAndRecipientByToken({ token }); await triggerWebhook({ eventTrigger: WebhookTriggerEvents.DOCUMENT_OPENED, - documentData: { - id: document.id, - userId: document.userId, - title: document.title, - status: document.status, - documentDataId: document.documentDataId, - createdAt: document.createdAt, - updatedAt: document.updatedAt, - completedAt: document.completedAt, - deletedAt: document.deletedAt, - teamId: document.teamId, - }, + documentData: document, }); }; From 70165c44692617f54518e1923e18c4fb561f4cbe Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 26 Feb 2024 22:24:23 +1100 Subject: [PATCH 12/22] chore: ui updates --- .../settings/webhooks/[id]/page.tsx | 96 ++++++++++++------- .../(dashboard)/settings/webhooks/page.tsx | 70 ++++++++------ .../settings/layout/desktop-nav.tsx | 26 ++--- .../settings/layout/mobile-nav.tsx | 26 ++--- .../webhooks/create-webhook-dialog.tsx | 90 ++++++++++------- ...x.tsx => trigger-multiselect-combobox.tsx} | 30 +++--- .../public-api/test-credentials.ts | 1 + .../lib/server-only/user/get-user-webhooks.ts | 17 ---- .../webhook/to-friendly-webhook-event-name.ts | 3 + 9 files changed, 210 insertions(+), 149 deletions(-) rename apps/web/src/components/(dashboard)/settings/webhooks/{multiselect-combobox.tsx => trigger-multiselect-combobox.tsx} (74%) delete mode 100644 packages/lib/server-only/user/get-user-webhooks.ts create mode 100644 packages/lib/universal/webhook/to-friendly-webhook-event-name.ts diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index b9f04790c..9c1dd0307 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -13,6 +13,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -24,7 +25,7 @@ import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; -import { MultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/multiselect-combobox'; +import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox'; const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true }); @@ -95,36 +96,77 @@ export default function WebhookPage({ params }: WebhookPageOptions) { )} -
- ( - - Webhook URL - - - - )} - /> +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
+ ( - - Event triggers + + Triggers - { onChange(values); }} /> + + + The events that will trigger a webhook to be sent to your URL. + + )} /> + + + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + )} /> - ( - - Active - - - - - - )} - />
+ + +
-
-
- - - -
))} 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 144cd0f06..6109d1f3d 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -51,19 +51,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { )} - - - - + + + + {isBillingEnabled && ( - - + + + + {isBillingEnabled && ( } - + Create webhook On this page, you can create a new webhook. @@ -104,34 +105,68 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting} > - ( - - Webhook URL - - - - - - )} - /> +
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
( - Event triggers + Triggers - { onChange(values); }} /> + + + The events that will trigger a webhook to be sent to your URL. + + )} @@ -150,24 +185,11 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr value={field.value ?? ''} /> - - - )} - /> - ( - - Active - - - + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + )} diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx similarity index 74% rename from apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx rename to apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx index 2adbaeb7a..5636f1931 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox.tsx @@ -1,8 +1,9 @@ -import * as React from 'react'; +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 { @@ -16,18 +17,21 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive import { truncateTitle } from '~/helpers/truncate-title'; -type ComboboxProps = { +type TriggerMultiSelectComboboxProps = { listValues: string[]; onChange: (_values: string[]) => void; }; -const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { - const [isOpen, setIsOpen] = React.useState(false); - const [selectedValues, setSelectedValues] = React.useState([]); +export const TriggerMultiSelectCombobox = ({ + listValues, + onChange, +}: TriggerMultiSelectComboboxProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState([]); const triggerEvents = Object.values(WebhookTriggerEvents); - React.useEffect(() => { + useEffect(() => { setSelectedValues(listValues); }, [listValues]); @@ -35,6 +39,7 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { const handleSelect = (currentValue: string) => { let newSelectedValues; + if (selectedValues.includes(currentValue)) { newSelectedValues = selectedValues.filter((value) => value !== currentValue); } else { @@ -59,9 +64,14 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { - + - + toFriendlyWebhookEventName(v)).join(', '), + 15, + )} + /> No value found. {allEvents.map((value: string, i: number) => ( @@ -72,7 +82,7 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { selectedValues.includes(value) ? 'opacity-100' : 'opacity-0', )} /> - {value} + {toFriendlyWebhookEventName(value)} ))} @@ -81,5 +91,3 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { ); }; - -export { MultiSelectCombobox }; diff --git a/packages/lib/server-only/public-api/test-credentials.ts b/packages/lib/server-only/public-api/test-credentials.ts index 02eb14cbf..4fa8bd16c 100644 --- a/packages/lib/server-only/public-api/test-credentials.ts +++ b/packages/lib/server-only/public-api/test-credentials.ts @@ -5,6 +5,7 @@ import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/val export const testCredentialsHandler = async (req: NextApiRequest, res: NextApiResponse) => { try { const { authorization } = req.headers; + const user = await validateApiToken({ authorization }); return res.status(200).json({ diff --git a/packages/lib/server-only/user/get-user-webhooks.ts b/packages/lib/server-only/user/get-user-webhooks.ts deleted file mode 100644 index 26c47e0f4..000000000 --- a/packages/lib/server-only/user/get-user-webhooks.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -export interface GetUserWebhooksByIdOptions { - id: number; -} - -export const getUserWebhooksById = async ({ id }: GetUserWebhooksByIdOptions) => { - return await prisma.user.findFirstOrThrow({ - where: { - id, - }, - select: { - email: true, - Webhooks: true, - }, - }); -}; 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(); +}; From a31057d0d1c2d8da6f47f84959daf9ecb38ef50d Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 26 Feb 2024 22:35:10 +1100 Subject: [PATCH 13/22] fix: add updated badge --- packages/ui/primitives/badge.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 }; From c2daa964c00dabb4d5036ac813b0e2f2a8521cbb Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 27 Feb 2024 12:13:56 +1100 Subject: [PATCH 14/22] chore: use cuids for webhooks --- .../settings/webhooks/[id]/page.tsx | 6 ++--- .../webhooks/delete-webhook-by-id.ts | 2 +- .../lib/server-only/webhooks/edit-webhook.ts | 2 +- .../server-only/webhooks/get-webhook-by-id.ts | 2 +- .../server-only/webhooks/zapier/subscribe.ts | 2 ++ .../webhooks/zapier/unsubscribe.ts | 2 ++ .../migration.sql | 12 +++++++++ packages/prisma/schema.prisma | 22 ++++++++-------- packages/trpc/server/webhook-router/schema.ts | 26 +++++++++---------- 9 files changed, 46 insertions(+), 30 deletions(-) create mode 100644 packages/prisma/migrations/20240227003622_migrate_to_cuids_for_webhooks/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index 9c1dd0307..c451482d4 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -33,7 +33,7 @@ type TEditWebhookFormSchema = z.infer; export type WebhookPageOptions = { params: { - id: number; + id: string; }; }; @@ -43,7 +43,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) { const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( { - id: Number(params.id), + id: params.id, }, { enabled: !!params.id }, ); @@ -63,7 +63,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) { const onSubmit = async (data: TEditWebhookFormSchema) => { try { await updateWebhook({ - id: Number(params.id), + id: params.id, ...data, }); diff --git a/packages/lib/server-only/webhooks/delete-webhook-by-id.ts b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts index 306d0ca9c..7821d310d 100644 --- a/packages/lib/server-only/webhooks/delete-webhook-by-id.ts +++ b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts @@ -1,7 +1,7 @@ import { prisma } from '@documenso/prisma'; export type DeleteWebhookByIdOptions = { - id: number; + id: string; userId: number; }; diff --git a/packages/lib/server-only/webhooks/edit-webhook.ts b/packages/lib/server-only/webhooks/edit-webhook.ts index 4177bb2bf..0717c5ad9 100644 --- a/packages/lib/server-only/webhooks/edit-webhook.ts +++ b/packages/lib/server-only/webhooks/edit-webhook.ts @@ -3,7 +3,7 @@ import type { Prisma } from '@prisma/client'; import { prisma } from '@documenso/prisma'; export type EditWebhookOptions = { - id: number; + id: string; data: Prisma.WebhookUpdateInput; userId: number; }; diff --git a/packages/lib/server-only/webhooks/get-webhook-by-id.ts b/packages/lib/server-only/webhooks/get-webhook-by-id.ts index 82dbb70ef..96fc2c4be 100644 --- a/packages/lib/server-only/webhooks/get-webhook-by-id.ts +++ b/packages/lib/server-only/webhooks/get-webhook-by-id.ts @@ -1,7 +1,7 @@ import { prisma } from '@documenso/prisma'; export type GetWebhookByIdOptions = { - id: number; + id: string; userId: number; }; diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts index 6fa22ab5f..af12c1daa 100644 --- a/packages/lib/server-only/webhooks/zapier/subscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -7,7 +7,9 @@ import { validateApiToken } from './validateApiToken'; export const subscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => { try { const { authorization } = req.headers; + const { webhookUrl, eventTrigger } = req.body; + const user = await validateApiToken({ authorization }); const createdWebhook = await prisma.webhook.create({ diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts index 30ee1e25a..7da0e8110 100644 --- a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts @@ -7,7 +7,9 @@ import { validateApiToken } from './validateApiToken'; export const unsubscribeHandler = async (req: NextApiRequest, res: NextApiResponse) => { try { const { authorization } = req.headers; + const { webhookId } = req.body; + const user = await validateApiToken({ authorization }); const deletedWebhook = await prisma.webhook.delete({ 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/schema.prisma b/packages/prisma/schema.prisma index bc6bbfd4b..bac5de1b7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -19,19 +19,19 @@ enum Role { } model User { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String? - customerId String? @unique - email String @unique + customerId String? @unique + email String @unique emailVerified DateTime? password String? source String? signature String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - lastSignedIn DateTime @default(now()) - roles Role[] @default([USER]) - identityProvider IdentityProvider @default(DOCUMENSO) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + lastSignedIn DateTime @default(now()) + roles Role[] @default([USER]) + identityProvider IdentityProvider @default(DOCUMENSO) accounts Account[] sessions Session[] Document Document[] @@ -41,9 +41,9 @@ model User { ownedPendingTeams TeamPending[] teamMembers TeamMember[] twoFactorSecret String? - twoFactorEnabled Boolean @default(false) + twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? - + VerificationToken VerificationToken[] ApiToken ApiToken[] Template Template[] @@ -106,7 +106,7 @@ enum WebhookTriggerEvents { } model Webhook { - id Int @id @default(autoincrement()) + id String @id @default(cuid()) webhookUrl String eventTriggers WebhookTriggerEvents[] secret String? diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index def654a70..8e0e31f42 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -11,22 +11,22 @@ export const ZCreateWebhookFormSchema = z.object({ enabled: z.boolean(), }); -export const ZGetWebhookByIdQuerySchema = z.object({ - id: z.number(), -}); - -export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({ - id: z.number(), -}); - -export const ZDeleteWebhookMutationSchema = z.object({ - id: z.number(), -}); - export type TCreateWebhookFormSchema = z.infer; +export const ZGetWebhookByIdQuerySchema = z.object({ + id: z.string(), +}); + export type TGetWebhookByIdQuerySchema = z.infer; -export type TDeleteWebhookMutationSchema = z.infer; +export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({ + id: z.string(), +}); export type TEditWebhookMutationSchema = z.infer; + +export const ZDeleteWebhookMutationSchema = z.object({ + id: z.string(), +}); + +export type TDeleteWebhookMutationSchema = z.infer; From 1ec549b8693ece271585627bbf3e17cd956589c1 Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 27 Feb 2024 12:54:37 +1100 Subject: [PATCH 15/22] chore: add webhook-call model --- .../settings/webhooks/[id]/page.tsx | 2 ++ .../migration.sql | 20 +++++++++++++++++++ packages/prisma/schema.prisma | 19 ++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packages/prisma/migrations/20240227015420_add_webhook_call_table/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index c451482d4..53ec24827 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -89,11 +89,13 @@ export default function WebhookPage({ params }: WebhookPageOptions) { title="Edit webhook" subtitle="On this page, you can edit the webhook and its settings." /> + {isLoading && (
)} +
Date: Tue, 27 Feb 2024 13:38:12 +1100 Subject: [PATCH 16/22] chore: add team to webhook model --- .../20240227023747_add_team_webhooks/migration.sql | 5 +++++ packages/prisma/schema.prisma | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 packages/prisma/migrations/20240227023747_add_team_webhooks/migration.sql 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/schema.prisma b/packages/prisma/schema.prisma index 3dd1a1997..76892ea59 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -115,6 +115,8 @@ model Webhook { 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[] } @@ -417,6 +419,7 @@ model Team { document Document[] templates Template[] ApiToken ApiToken[] + Webhook Webhook[] } model TeamPending { From 7dd2bbd8ab47612ec9bebdfeeec77da0fee8d7c9 Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 27 Feb 2024 15:16:14 +1100 Subject: [PATCH 17/22] feat: update webhook handling and triggering --- apps/web/src/pages/api/webhook/trigger.ts | 12 ++++ packages/lib/server-only/crypto/sign.ts | 12 ++++ packages/lib/server-only/crypto/verify.ts | 12 ++++ .../document/complete-document-with-token.ts | 8 ++- .../server-only/document/create-document.ts | 8 ++- .../lib/server-only/document/seal-document.ts | 8 ++- .../server-only/document/send-document.tsx | 8 ++- .../server-only/document/viewed-document.ts | 8 ++- .../get-all-webhooks-by-event-trigger.ts | 25 +++++++- .../webhooks/trigger/execute-webhook.ts | 58 +++++++++++++++++++ .../server-only/webhooks/trigger/handler.ts | 58 +++++++++++++++++++ .../server-only/webhooks/trigger/schema.ts | 12 ++++ .../webhooks/trigger/trigger-webhook.ts | 40 +++++++++++++ .../lib/universal/post-webhook-payload.ts | 39 ------------- packages/lib/universal/trigger-webhook.ts | 30 ---------- .../migration.sql | 8 +++ packages/prisma/schema.prisma | 7 ++- 17 files changed, 263 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/pages/api/webhook/trigger.ts create mode 100644 packages/lib/server-only/crypto/sign.ts create mode 100644 packages/lib/server-only/crypto/verify.ts create mode 100644 packages/lib/server-only/webhooks/trigger/execute-webhook.ts create mode 100644 packages/lib/server-only/webhooks/trigger/handler.ts create mode 100644 packages/lib/server-only/webhooks/trigger/schema.ts create mode 100644 packages/lib/server-only/webhooks/trigger/trigger-webhook.ts delete mode 100644 packages/lib/universal/post-webhook-payload.ts delete mode 100644 packages/lib/universal/trigger-webhook.ts create mode 100644 packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql diff --git a/apps/web/src/pages/api/webhook/trigger.ts b/apps/web/src/pages/api/webhook/trigger.ts new file mode 100644 index 000000000..88abbb3b6 --- /dev/null +++ b/apps/web/src/pages/api/webhook/trigger.ts @@ -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; 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 ae729e200..5f58c5183 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -7,7 +7,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -134,7 +134,9 @@ export const completeDocumentWithToken = async ({ const updatedDocument = await getDocument({ token, documentId }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED, - documentData: updatedDocument, + 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 d7d8b58e4..ce1f16670 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -7,7 +7,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit- import { prisma } from '@documenso/prisma'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type CreateDocumentOptions = { title: string; @@ -67,8 +67,10 @@ export const createDocument = async ({ }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED, - documentData: document, + 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 7b27e402a..8f39e3d25 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -13,10 +13,10 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { signPdf } from '@documenso/signing'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; -import { triggerWebhook } from '../../universal/trigger-webhook'; 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 = { @@ -139,7 +139,9 @@ export const sealDocument = async ({ } await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_COMPLETED, - documentData: document, + 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 d822e4a53..7c928f9a9 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -17,7 +17,7 @@ import { RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '../../constants/recipient-roles'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type SendDocumentOptions = { documentId: number; @@ -189,8 +189,10 @@ export const sendDocument = async ({ }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_SENT, - documentData: updatedDocument, + 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 3e895882d..9722b4fbf 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -5,7 +5,7 @@ import { prisma } from '@documenso/prisma'; import { ReadStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { getDocumentAndRecipientByToken } from './get-document-by-token'; export type ViewedDocumentOptions = { @@ -59,7 +59,9 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO const document = await getDocumentAndRecipientByToken({ token }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_OPENED, - documentData: document, + event: WebhookTriggerEvents.DOCUMENT_OPENED, + data: document, + userId: document.userId, + teamId: document.teamId ?? undefined, }); }; 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 index 9ce31477a..82882c69f 100644 --- 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 @@ -2,16 +2,35 @@ import { prisma } from '@documenso/prisma'; import type { WebhookTriggerEvents } from '@documenso/prisma/client'; export type GetAllWebhooksByEventTriggerOptions = { - eventTrigger: WebhookTriggerEvents; + event: WebhookTriggerEvents; + userId: number; + teamId?: number; }; export const getAllWebhooksByEventTrigger = async ({ - eventTrigger, + event, + userId, + teamId, }: GetAllWebhooksByEventTriggerOptions) => { return prisma.webhook.findMany({ where: { + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), eventTriggers: { - has: eventTrigger, + has: event, }, enabled: true, }, 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/universal/post-webhook-payload.ts b/packages/lib/universal/post-webhook-payload.ts deleted file mode 100644 index 80ddea80d..000000000 --- a/packages/lib/universal/post-webhook-payload.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Document, Webhook } from '@documenso/prisma/client'; - -export type PostWebhookPayloadOptions = { - webhookData: Pick; - documentData: Document; -}; - -export const postWebhookPayload = async ({ - webhookData, - documentData, -}: PostWebhookPayloadOptions) => { - const { webhookUrl, secret } = webhookData; - - const payload = { - event: webhookData.eventTriggers.toString(), - createdAt: new Date().toISOString(), - webhookEndpoint: webhookUrl, - payload: documentData, - }; - - const response = await fetch(webhookUrl, { - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - 'X-Documenso-Secret': secret ?? '', - }, - }); - - if (!response.ok) { - throw new Error(`Webhook failed with the status code ${response.status}`); - } - - return { - status: response.status, - statusText: response.statusText, - message: 'Webhook sent successfully', - }; -}; diff --git a/packages/lib/universal/trigger-webhook.ts b/packages/lib/universal/trigger-webhook.ts deleted file mode 100644 index 72484d6c3..000000000 --- a/packages/lib/universal/trigger-webhook.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client'; - -import { getAllWebhooksByEventTrigger } from '../server-only/webhooks/get-all-webhooks-by-event-trigger'; -import { postWebhookPayload } from './post-webhook-payload'; - -export type TriggerWebhookOptions = { - eventTrigger: WebhookTriggerEvents; - documentData: Document; -}; - -export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => { - try { - const allWebhooks = await getAllWebhooksByEventTrigger({ eventTrigger }); - - const webhookPromises = allWebhooks.map((webhook) => { - const { webhookUrl, secret } = webhook; - - postWebhookPayload({ - webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] }, - documentData, - }).catch((_err) => { - throw new Error(`Failed to send webhook to ${webhookUrl}`); - }); - }); - - return Promise.all(webhookPromises); - } catch (err) { - throw new Error(`Failed to trigger webhook`); - } -}; 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 76892ea59..873d5ff63 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -126,16 +126,17 @@ enum WebhookCallStatus { } model WebhookCall { - id String @id @default(cuid()) + id String @id @default(cuid()) status WebhookCallStatus url String + event WebhookTriggerEvents requestBody Json responseCode Int responseHeaders Json? responseBody Json? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) webhookId String - webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) } enum ApiTokenAlgorithm { From a4b1f7c9830893eeda54c47e80a1b3567aaacff3 Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 27 Feb 2024 16:56:32 +1100 Subject: [PATCH 18/22] feat: support team webhooks --- .../[teamUrl]/settings/webhooks/[id]/page.tsx | 206 ++++++++++++++++++ .../t/[teamUrl]/settings/webhooks/page.tsx | 101 +++++++++ .../webhooks/create-webhook-dialog.tsx | 24 +- .../webhooks/delete-webhook-dialog.tsx | 7 +- .../(teams)/settings/layout/desktop-nav.tsx | 16 +- .../(teams)/settings/layout/mobile-nav.tsx | 16 +- apps/web/src/providers/team.tsx | 4 +- .../server-only/webhooks/create-webhook.ts | 16 ++ .../webhooks/delete-webhook-by-id.ts | 19 +- .../lib/server-only/webhooks/edit-webhook.ts | 21 +- .../get-all-webhooks-by-event-trigger.ts | 8 +- .../server-only/webhooks/get-webhook-by-id.ts | 19 +- .../webhooks/get-webhooks-by-team-id.ts | 19 ++ packages/trpc/server/webhook-router/router.ts | 43 +++- packages/trpc/server/webhook-router/schema.ts | 15 +- 15 files changed, 505 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx create mode 100644 packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts 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 && ( +
+ +
+ )} + + + +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + Triggers + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + Secret + + + + + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + + + + )} + /> + +
+ +
+
+ + +
+ ); +} diff --git a/apps/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..4e41b4de6 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/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'; +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{' '} + +

+
+ +
+ + + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 2732971b1..727054655 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -10,7 +10,7 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateWebhookFormSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -35,8 +35,12 @@ 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; export type CreateWebhookDialogProps = { @@ -46,6 +50,9 @@ export type CreateWebhookDialogProps = { export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { const router = useRouter(); const { toast } = useToast(); + + const team = useOptionalCurrentTeam(); + const [open, setOpen] = useState(false); const form = useForm({ @@ -60,9 +67,20 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation(); - const onSubmit = async (values: TCreateWebhookFormSchema) => { + const onSubmit = async ({ + enabled, + eventTriggers, + secret, + webhookUrl, + }: TCreateWebhookFormSchema) => { try { - await createWebhook(values); + await createWebhook({ + enabled, + eventTriggers, + secret, + webhookUrl, + teamId: team?.id, + }); setOpen(false); 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 index 8f4a4008f..e65ae78b8 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -31,6 +31,8 @@ import { 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; @@ -40,6 +42,9 @@ export type DeleteWebhookDialogProps = { 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}`; @@ -63,7 +68,7 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr const onSubmit = async () => { try { - await deleteWebhook({ id: webhook.id }); + await deleteWebhook({ id: webhook.id, teamId: team?.id }); toast({ title: 'Webhook deleted', 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) => { + + + + {IS_BILLING_ENABLED() && ( + + + + {IS_BILLING_ENABLED() && ( From c8869b308858feda8cd067cb33c13f496857288e Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 28 Feb 2024 09:18:05 +0200 Subject: [PATCH 22/22] chore: fixed UI for webhooks team --- .../src/app/(dashboard)/settings/webhooks/page.tsx | 5 ++++- .../(teams)/t/[teamUrl]/settings/webhooks/page.tsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index dcb90712b..01196544d 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -58,7 +58,10 @@ export default function WebhookPage() {
{webhook.id}
-
+
{webhook.webhookUrl}
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 index 4e41b4de6..054664624 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx @@ -58,12 +58,17 @@ export default function WebhookPage() { !webhook.enabled && 'bg-muted/40', )} > -
+
{webhook.id}
-
{webhook.webhookUrl}
+
+ {webhook.webhookUrl} +
{webhook.enabled ? 'Enabled' : 'Disabled'} @@ -83,7 +88,7 @@ export default function WebhookPage() {

-
+