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..b9f04790c --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -0,0 +1,169 @@ +'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, + 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 { 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, isLoading } = 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 ( +
+ + {isLoading && ( +
+ +
+ )} +
+ +
+ ( + + 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 new file mode 100644 index 000000000..d36de6726 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +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'; + +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() { + const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery(); + + return ( +
+ + + + + {isLoading && ( +
+ +
+ )} + + {webhooks && webhooks.length === 0 && ( + // TODO: Perhaps add some illustrations here to make the page more engaging +
+

+ You have no webhooks yet. Your webhooks will be shown here once you create them. +

+
+ )} + + {webhooks && webhooks.length > 0 && ( +
+ {webhooks?.map((webhook) => ( +
+
+
+

Webhook URL

+

{webhook.webhookUrl}

+

Event triggers

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

+ Active +

+ ) : ( +

+ Inactive +

+ )} +
+
+
+ + + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index e87c47b67..144cd0f06 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Braces, CreditCard, Lock, User, Users } from 'lucide-react'; +import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -51,6 +51,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { )} + + + + + + } + + + + + Create webhook + On this page, you can create a new webhook. + + +
+ +
+ ( + + Webhook URL + + + + + + )} + /> + + ( + + Event triggers + + { + onChange(values); + }} + /> + + + + )} + /> + + ( + + Secret + + + + + + )} + /> + + ( + + Active + + + + + + )} + /> + + +
+ + +
+
+
+
+ +
+ + ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx new file mode 100644 index 000000000..8f4a4008f --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -0,0 +1,167 @@ +'use effect'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { Webhook } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteWebhookDialogProps = { + webhook: Pick; + onDelete?: () => void; + children: React.ReactNode; +}; + +export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + + const deleteMessage = `delete ${webhook.webhookUrl}`; + + const ZDeleteWebhookFormSchema = z.object({ + webhookUrl: z.literal(deleteMessage, { + errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }), + }), + }); + + type TDeleteWebhookFormSchema = z.infer; + + const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZDeleteWebhookFormSchema), + values: { + webhookUrl: '', + }, + }); + + const onSubmit = async () => { + try { + await deleteWebhook({ id: webhook.id }); + + toast({ + title: 'Webhook deleted', + duration: 5000, + description: 'The webhook has been successfully deleted.', + }); + + setOpen(false); + + router.refresh(); + } catch (error) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 5000, + description: + 'We encountered an unknown error while attempting to delete it. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {children ?? ( + + )} + + + + + Delete 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/(dashboard)/settings/webhooks/multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx new file mode 100644 index 000000000..2adbaeb7a --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx @@ -0,0 +1,85 @@ +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'; + +import { truncateTitle } from '~/helpers/truncate-title'; + +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/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/package.json b/package.json index 17a6a53ca..b4c71ff25 100644 --- a/package.json +++ b/package.json @@ -54,5 +54,8 @@ "next-contentlayer": { "next": "14.0.3" } + }, + "dependencies": { + "next-runtime-env": "^3.2.0" } } diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index b0e7e024f..ae729e200 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -5,7 +5,9 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import { triggerWebhook } from '../../universal/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -15,14 +17,8 @@ export type CompleteDocumentWithTokenOptions = { requestMetadata?: RequestMetadata; }; -export const completeDocumentWithToken = async ({ - token, - documentId, - requestMetadata, -}: CompleteDocumentWithTokenOptions) => { - 'use server'; - - const document = await prisma.document.findFirstOrThrow({ +const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => { + return await prisma.document.findFirstOrThrow({ where: { id: documentId, Recipient: { @@ -39,6 +35,16 @@ export const completeDocumentWithToken = async ({ }, }, }); +}; + +export const completeDocumentWithToken = async ({ + token, + documentId, + requestMetadata, +}: CompleteDocumentWithTokenOptions) => { + 'use server'; + + const document = await getDocument({ token, documentId }); if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); @@ -124,4 +130,11 @@ export const completeDocumentWithToken = async ({ if (documents.count > 0) { await sealDocument({ documentId: document.id, requestMetadata }); } + + 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 7243652f0..d7d8b58e4 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,6 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; +import { WebhookTriggerEvents } from '@documenso/prisma/client'; + +import { triggerWebhook } from '../../universal/trigger-webhook'; export type CreateDocumentOptions = { title: string; @@ -63,6 +66,11 @@ export const createDocument = async ({ }), }); + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED, + documentData: document, + }); + return document; }); }; diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 0dbda803e..eeafe0b90 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client'; -import { getDocumentWhereInput } from './get-document-by-id'; - import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { getDocumentWhereInput } from './get-document-by-id'; export type ResendDocumentOptions = { documentId: 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/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/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/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-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/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/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/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`); + } +}; 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 0712beaf5..8d0731866 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,12 +41,13 @@ model User { ownedPendingTeams TeamPending[] teamMembers TeamMember[] twoFactorSecret String? - twoFactorEnabled Boolean @default(false) + twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? VerificationToken VerificationToken[] ApiToken ApiToken[] - Template Template[] - securityAuditLogs UserSecurityAuditLog[] + Template Template[] + securityAuditLogs UserSecurityAuditLog[] + Webhooks Webhook[] @@index([email]) } @@ -109,6 +110,23 @@ model ApiToken { 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 @@ -225,15 +243,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 { 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))', diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 72fe0b2be..16f79f712 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -12,6 +12,7 @@ import { teamRouter } from './team-router/router'; import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; +import { webhookRouter } from './webhook-router/router'; export const appRouter = router({ auth: authRouter, @@ -26,6 +27,7 @@ export const appRouter = router({ singleplayer: singleplayerRouter, team: teamRouter, template: templateRouter, + webhook: webhookRouter, twoFactorAuthentication: twoFactorAuthenticationRouter, }); diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts new file mode 100644 index 000000000..aeb7e6f38 --- /dev/null +++ b/packages/trpc/server/webhook-router/router.ts @@ -0,0 +1,96 @@ +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, + ZDeleteWebhookMutationSchema, + ZEditWebhookMutationSchema, + ZGetWebhookByIdQuerySchema, +} from './schema'; + +export const webhookRouter = router({ + getWebhooks: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getWebhooksByUserId(ctx.user.id); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to fetch your webhooks. Please try again later.', + }); + } + }), + 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 }) => { + 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.', + }); + } + }), + deleteWebhook: authenticatedProcedure + .input(ZDeleteWebhookMutationSchema) + .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.', + }); + } + }), + 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 new file mode 100644 index 000000000..def654a70 --- /dev/null +++ b/packages/trpc/server/webhook-router/schema.ts @@ -0,0 +1,32 @@ +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 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 TGetWebhookByIdQuerySchema = z.infer; + +export type TDeleteWebhookMutationSchema = z.infer; + +export type TEditWebhookMutationSchema = z.infer;