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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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 27/31] 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() {

-
+
From 1590fa94574764b071c52500cfb2f55b04cbd978 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 28 Feb 2024 12:13:48 +0100 Subject: [PATCH 28/31] chore: text --- apps/marketing/content/blog/launch-week-2-day-3.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/marketing/content/blog/launch-week-2-day-3.mdx b/apps/marketing/content/blog/launch-week-2-day-3.mdx index 98ee2d3c4..a3278396d 100644 --- a/apps/marketing/content/blog/launch-week-2-day-3.mdx +++ b/apps/marketing/content/blog/launch-week-2-day-3.mdx @@ -28,6 +28,7 @@ Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that devel - Get Documents (Individual or all Accessible) - Upload documents - Delete Documents +- Create documents from templates - Trigger Sending Documents for Singing You can check out the detailed API documentation here: From 3510d8b6b03021707b91d4fff8b61f21f0ba09da Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 28 Feb 2024 12:14:19 +0100 Subject: [PATCH 29/31] chore: text --- apps/marketing/content/blog/launch-week-2-day-3.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/marketing/content/blog/launch-week-2-day-3.mdx b/apps/marketing/content/blog/launch-week-2-day-3.mdx index a3278396d..ae7ff8a84 100644 --- a/apps/marketing/content/blog/launch-week-2-day-3.mdx +++ b/apps/marketing/content/blog/launch-week-2-day-3.mdx @@ -26,9 +26,9 @@ tags: Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can: - Get Documents (Individual or all Accessible) -- Upload documents +- Upload Documents - Delete Documents -- Create documents from templates +- Create Documents from Templates - Trigger Sending Documents for Singing You can check out the detailed API documentation here: From dd81f946b4746423249132819ce969a9aa3c8d83 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 28 Feb 2024 12:23:21 +0100 Subject: [PATCH 30/31] chore: cta --- apps/marketing/content/blog/launch-week-2-day-3.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/marketing/content/blog/launch-week-2-day-3.mdx b/apps/marketing/content/blog/launch-week-2-day-3.mdx index ae7ff8a84..2e0e47a35 100644 --- a/apps/marketing/content/blog/launch-week-2-day-3.mdx +++ b/apps/marketing/content/blog/launch-week-2-day-3.mdx @@ -39,6 +39,8 @@ You can check out the detailed API documentation here: We are building Documenso to be an open and extendable platform; therefore, therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs. +> Try the API here for free: [https://documen.so/api](https://documen.so/api) + ## What's next for the API You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better? From f376c7b9510baf568ec96a0e4f42f717ecddf88a Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 28 Feb 2024 13:38:10 +0100 Subject: [PATCH 31/31] chore: typo --- apps/marketing/content/blog/launch-week-2-day-3.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/launch-week-2-day-3.mdx b/apps/marketing/content/blog/launch-week-2-day-3.mdx index 2e0e47a35..6ea0db9b9 100644 --- a/apps/marketing/content/blog/launch-week-2-day-3.mdx +++ b/apps/marketing/content/blog/launch-week-2-day-3.mdx @@ -37,7 +37,7 @@ You can check out the detailed API documentation here: ## Pricing -We are building Documenso to be an open and extendable platform; therefore, therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs. +We are building Documenso to be an open and extendable platform; therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost, as can teams. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs. > Try the API here for free: [https://documen.so/api](https://documen.so/api)