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 {