Compare commits

..

10 Commits

17 changed files with 688 additions and 932 deletions

View File

@ -22,15 +22,6 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.completed` - `document.completed`
- `document.rejected` - `document.rejected`
- `document.cancelled` - `document.cancelled`
- `document.viewed`
- `document.recipient.completed`
- `document.downloaded`
- `document.reminder.sent`
- `template.created`
- `template.updated`
- `template.deleted`
- `template.used`
- `recipient.authentication.failed`
## Create a webhook subscription ## Create a webhook subscription
@ -47,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information: To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload. - Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`, `document.viewed`, `document.recipient.completed`, `document.downloaded`, `document.reminder.sent`, `template.created`, `template.updated`, `template.deleted`, `template.used`, `recipient.authentication.failed`. - Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request. - Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp) ![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@ -628,591 +619,6 @@ Example payload for the `document.rejected` event:
} }
``` ```
Example payload for the `document.viewed` event:
```json
{
"event": "DOCUMENT_VIEWED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:50:26.174Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.recipient.completed` event:
```json
{
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:51:10.055Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 50,
"documentId": 10,
"templateId": null,
"email": "signer1@documenso.com",
"name": "Signer 1",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
},
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer2@documenso.com",
"name": "Signer 2",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 2,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:51:10.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.downloaded` event:
```json
{
"event": "DOCUMENT_DOWNLOADED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:53:18.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.reminder.sent` event:
```json
{
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T12:00:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.created` event:
```json
{
"event": "TEMPLATE_CREATED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T11:44:44.779Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.updated` event:
```json
{
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract_v2.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T12:30:00.000Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract - Updated",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T12:30:01.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.deleted` event:
```json
{
"event": "TEMPLATE_DELETED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T13:00:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.used` event:
```json
{
"event": "TEMPLATE_USED",
"payload": {
"id": 15,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "new_doc_data_123",
"createdAt": "2024-04-22T14:00:00.000Z",
"updatedAt": "2024-04-22T14:00:00.000Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_789",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 60,
"documentId": 15,
"templateId": 5,
"email": "newemployee@company.com",
"name": "New Employee",
"token": "DocToken456",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T14:00:01.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `recipient.authentication.failed` event:
```json
{
"event": "RECIPIENT_AUTHENTICATION_FAILED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": {
"accessAuth": "TWO_FACTOR_AUTH",
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:49:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Webhook Events Testing ## Webhook Events Testing
You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button. You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button.

13
package-lock.json generated
View File

@ -19,6 +19,7 @@
"inngest-cli": "^0.29.1", "inngest-cli": "^0.29.1",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "3.24.1" "zod": "3.24.1"
@ -27198,6 +27199,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf2json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
"license": "Apache-2.0",
"bin": {
"pdf2json": "bin/pdf2json.js"
},
"engines": {
"node": ">=20.18.0"
}
},
"node_modules/pdfjs-dist": { "node_modules/pdfjs-dist": {
"version": "3.11.174", "version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",

View File

@ -74,6 +74,7 @@
"inngest-cli": "^0.29.1", "inngest-cli": "^0.29.1",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "3.24.1" "zod": "3.24.1"

View File

@ -0,0 +1,98 @@
import { expect, test } from '@playwright/test';
import path from 'path';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const PLACEHOLDER_PDF_PATH = path.join(
__dirname,
'../../../assets/project-proposal-single-recipient.pdf',
);
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(PLACEHOLDER_PDF_PATH);
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
await expect(page.getByPlaceholder('Name')).toHaveValue('Recipient 1');
});
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
page,
}) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(PLACEHOLDER_PDF_PATH);
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.locator('[data-field-type="SIGNATURE"]')).toBeVisible();
await expect(page.locator('[data-field-type="EMAIL"]')).toBeVisible();
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
});
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
page,
}) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(PLACEHOLDER_PDF_PATH);
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Advanced settings' }).click();
await expect(page.getByRole('heading', { name: 'Advanced settings' })).toBeVisible();
await expect(
page
.locator('div')
.filter({ hasText: /^Required field$/ })
.getByRole('switch'),
).toBeChecked();
await expect(page.getByRole('combobox')).toHaveText('Right');
});
});

View File

@ -97,9 +97,7 @@ export const completeDocumentWithToken = async ({
} }
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
token: recipient.token,
});
if (!isRecipientsTurn) { if (!isRecipientsTurn) {
throw new Error( throw new Error(
@ -153,18 +151,6 @@ export const completeDocumentWithToken = async ({
}), }),
}); });
const envelopeForFailure = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.RECIPIENT_AUTHENTICATION_FAILED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForFailure)),
userId: envelope.userId,
teamId: envelope.teamId,
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, { throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication', message: 'Invalid 2FA authentication',
}); });
@ -219,18 +205,6 @@ export const completeDocumentWithToken = async ({
}); });
}); });
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await jobs.triggerJob({ await jobs.triggerJob({
name: 'send.recipient.signed.email', name: 'send.recipient.signed.email',
payload: { payload: {

View File

@ -7,7 +7,6 @@ import {
OrganisationType, OrganisationType,
RecipientRole, RecipientRole,
SigningStatus, SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client'; } from '@prisma/client';
import { mailer } from '@documenso/email/mailer'; import { mailer } from '@documenso/email/mailer';
@ -25,16 +24,11 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { isDocumentCompleted } from '../../utils/document'; import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = { export type ResendDocumentOptions = {
id: EnvelopeIdOptions; id: EnvelopeIdOptions;
@ -236,11 +230,4 @@ export const resendDocument = async ({
); );
}), }),
); );
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
}; };

View File

@ -1,4 +1,5 @@
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client'; import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -65,13 +66,6 @@ export const viewedDocument = async ({
}), }),
}); });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_VIEWED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Early return if already opened. // Early return if already opened.
if (recipient.readStatus === ReadStatus.OPENED) { if (recipient.readStatus === ReadStatus.OPENED) {
return; return;

View File

@ -10,6 +10,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { insertFieldsFromPlaceholdersInPDF } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -233,7 +234,7 @@ export const createEnvelope = async ({
? await incrementDocumentId().then((v) => v.formattedDocumentId) ? await incrementDocumentId().then((v) => v.formattedDocumentId)
: await incrementTemplateId().then((v) => v.formattedTemplateId); : await incrementTemplateId().then((v) => v.formattedTemplateId);
return await prisma.$transaction(async (tx) => { const createdEnvelope = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({ const envelope = await tx.envelope.create({
data: { data: {
id: prefixedId('envelope'), id: prefixedId('envelope'),
@ -353,8 +354,12 @@ export const createEnvelope = async ({
recipients: true, recipients: true,
fields: true, fields: true,
folder: true, folder: true,
envelopeItems: true,
envelopeAttachments: true, envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
}, },
}); });
@ -386,15 +391,55 @@ export const createEnvelope = async ({
userId, userId,
teamId, teamId,
}); });
} else if (type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
} }
return createdEnvelope; return createdEnvelope;
}); });
for (const envelopeItem of createdEnvelope.envelopeItems) {
const buffer = await getFileServerSide(envelopeItem.documentData);
// Use normalized PDF if normalizePdf was true, otherwise use original
const pdfToProcess = normalizePdf
? await makeNormalizedPdf(Buffer.from(buffer))
: Buffer.from(buffer);
await insertFieldsFromPlaceholdersInPDF(
pdfToProcess,
userId,
teamId,
{
type: 'envelopeId',
id: createdEnvelope.id,
},
requestMetadata,
envelopeItem.id,
);
}
const finalEnvelope = await prisma.envelope.findFirst({
where: {
id: createdEnvelope.id,
},
include: {
documentMeta: true,
recipients: true,
fields: true,
folder: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!finalEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return finalEnvelope;
}; };

View File

@ -1,5 +1,6 @@
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client'; import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, FolderType, WebhookTriggerEvents } from '@prisma/client'; import { EnvelopeType, FolderType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -11,14 +12,9 @@ import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams'; import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id'; import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = { export type UpdateEnvelopeOptions = {
@ -343,22 +339,6 @@ export const updateEnvelope = async ({
}); });
} }
if (envelope.type === EnvelopeType.TEMPLATE) {
const envelopeWithRelations = await tx.envelope.findUniqueOrThrow({
where: { id: updatedEnvelope.id },
include: { documentMeta: true, recipients: true },
});
void triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations),
),
userId,
teamId,
});
}
return updatedEnvelope; return updatedEnvelope;
}); });
}; };

View File

@ -0,0 +1,511 @@
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import PDFParser from 'pdf2json';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
import { createDocumentRecipients } from '@documenso/lib/server-only/recipient/create-document-recipients';
import { createTemplateRecipients } from '@documenso/lib/server-only/recipient/create-template-recipients';
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { getPageSize } from './get-page-size';
type TextPosition = {
text: string;
x: number;
y: number;
w: number;
};
type CharIndexMapping = {
textPosIndex: number;
};
type PlaceholderInfo = {
placeholder: string;
recipient: string;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
type FieldToCreate = TFieldAndMeta & {
envelopeItemId?: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
};
type RecipientPlaceholderInfo = {
email: string;
name: string;
recipientIndex: number;
};
/*
Questions for later:
- Does it handle multi-page PDFs? ✅ YES! ✅
- Does it handle multiple recipients on the same page? ✅ YES! ✅
- Does it handle multiple recipients on multiple pages? ✅ YES! ✅
- What happens with incorrect placeholders? E.g. those containing non-accepted properties.
- The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing. ✅
- Need to handle envelopes with multiple items. ✅
*/
/*
Parse field type string to FieldType enum.
Normalizes the input (uppercase, trim) and validates it's a valid field type.
This ensures we handle case variations and whitespace, and provides clear error messages.
*/
const parseFieldType = (fieldTypeString: string): FieldType => {
const normalizedType = fieldTypeString.toUpperCase().trim();
return match(normalizedType)
.with('SIGNATURE', () => FieldType.SIGNATURE)
.with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE)
.with('INITIALS', () => FieldType.INITIALS)
.with('NAME', () => FieldType.NAME)
.with('EMAIL', () => FieldType.EMAIL)
.with('DATE', () => FieldType.DATE)
.with('TEXT', () => FieldType.TEXT)
.with('NUMBER', () => FieldType.NUMBER)
.with('RADIO', () => FieldType.RADIO)
.with('CHECKBOX', () => FieldType.CHECKBOX)
.with('DROPDOWN', () => FieldType.DROPDOWN)
.otherwise(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field type: ${fieldTypeString}`,
});
});
};
/*
Transform raw field metadata from placeholder format to schema format.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
Converts string values to proper types (booleans, numbers).
*/
const parseFieldMeta = (
rawFieldMeta: Record<string, string>,
fieldType: FieldType,
): Record<string, unknown> | undefined => {
if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) {
return;
}
if (Object.keys(rawFieldMeta).length === 0) {
return;
}
const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, boolean | number | string> = {
type: fieldTypeString,
};
/*
rawFieldMeta is an object with string keys and string values.
It contains string values because the PDF parser returns the values as strings.
E.g. { required: 'true', fontSize: '12', maxValue: '100', minValue: '0', characterLimit: '100' }
*/
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const entry of rawFieldMetaEntries) {
const [key, value] = entry;
if (key === 'readOnly' || key === 'required') {
parsedFieldMeta[key] = value === 'true';
} else if (
key === 'fontSize' ||
key === 'maxValue' ||
key === 'minValue' ||
key === 'characterLimit'
) {
const numValue = Number(value);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[key] = numValue;
}
} else {
parsedFieldMeta[key] = value;
}
}
return parsedFieldMeta;
};
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
return new Promise((resolve, reject) => {
const parser = new PDFParser(null, true);
parser.on('pdfParser_dataError', (errData) => {
reject(errData);
});
parser.on('pdfParser_dataReady', (pdfData) => {
const placeholders: PlaceholderInfo[] = [];
pdfData.Pages.forEach((page, pageIndex) => {
/*
pdf2json returns the PDF page content as an array of characters.
We need to concatenate the characters to get the full text.
We also need to get the position of the text so we can place the placeholders in the correct position.
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
*/
const pageWidth = page.Width;
const pageHeight = page.Height;
let pageText = '';
const textPositions: TextPosition[] = [];
const charIndexToTextPos: CharIndexMapping[] = [];
page.Texts.forEach((text) => {
/*
R is an array that contains objects with each character.
The decodedText contains only the character, without any other information.
textPositions stores each character and its position on the page.
*/
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
for (let i = 0; i < decodedText.length; i++) {
charIndexToTextPos.push({
textPosIndex: textPositions.length,
});
}
pageText += decodedText;
textPositions.push({
text: decodedText,
x: text.x,
y: text.y,
w: text.w || 0,
});
});
const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g);
for (const placeholderMatch of placeholderMatches) {
const placeholder = placeholderMatch[0];
const placeholderData = placeholderMatch[1].split(',').map((part) => part.trim());
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((meta) => meta.split('=')));
const fieldType = parseFieldType(fieldTypeString);
const parsedFieldMeta = parseFieldMeta(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
Find the position of where the placeholder starts in the text
Then find the position of where the placeholder ends in the text by adding the length of the placeholder to the index of the placeholder.
*/
const matchIndex = placeholderMatch.index;
if (matchIndex === undefined) {
continue;
}
const placeholderLength = placeholder.length;
const placeholderEndIndex = matchIndex + placeholderLength;
const startCharInfo = charIndexToTextPos[matchIndex];
const endCharInfo = charIndexToTextPos[placeholderEndIndex - 1];
if (!startCharInfo || !endCharInfo) {
console.error('Could not find text position for placeholder', placeholder);
return;
}
const startTextPos = textPositions[startCharInfo.textPosIndex];
const endTextPos = textPositions[endCharInfo.textPosIndex];
/*
PDF2JSON coordinates - these are in "page units" (relative coordinates)
Calculate width as the distance from start to end, plus a portion of the last character's width
Use 10% of the last character width to avoid extending too far beyond the placeholder
*/
const x = startTextPos.x;
const y = startTextPos.y;
const width = endTextPos.x + endTextPos.w * 0.1 - startTextPos.x;
placeholders.push({
placeholder,
recipient,
fieldAndMeta,
page: pageIndex + 1,
x,
y,
width,
height: 1,
pageWidth,
pageHeight,
});
}
});
resolve(placeholders);
});
parser.parseBuffer(pdf);
});
};
export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
const pages = pdfDoc.getPages();
for (const placeholder of placeholders) {
const pageIndex = placeholder.page - 1;
const page = pages[pageIndex];
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
/*
Convert PDF2JSON coordinates to pdf-lib coordinates:
PDF2JSON uses relative "page units":
- x, y, width, height are in page units
- Page dimensions (Width, Height) are also in page units
pdf-lib uses absolute points (1 point = 1/72 inch):
- Need to convert from page units to points
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
- Y-axis in PDF2JSON is top-down (origin at top-left)
Conversion formulas:
- x_points = (x / pageWidth) * pdfLibPageWidth
- y_points = pdfLibPageHeight - ((y / pageHeight) * pdfLibPageHeight)
- width_points = (width / pageWidth) * pdfLibPageWidth
- height_points = (height / pageHeight) * pdfLibPageHeight
*/
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
page.drawRectangle({
x: xPoints,
y: yPoints - heightPoints, // Adjust for height since y is at baseline
width: widthPoints,
height: heightPoints,
color: rgb(1, 1, 1),
borderColor: rgb(1, 1, 1),
borderWidth: 2,
});
}
const modifiedPdfBytes = await pdfDoc.save();
return Buffer.from(modifiedPdfBytes);
};
const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
const indexMatch = placeholder.match(/^r(\d+)$/i);
if (!indexMatch) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`,
});
}
const recipientIndex = Number(indexMatch[1]);
return {
email: `recipient.${recipientIndex}@documenso.com`,
name: `Recipient ${recipientIndex}`,
recipientIndex,
};
};
export const insertFieldsFromPlaceholdersInPDF = async (
pdf: Buffer,
userId: number,
teamId: number,
envelopeId: EnvelopeIdOptions,
requestMetadata: ApiRequestMetadata,
envelopeItemId?: string,
): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
if (placeholders.length === 0) {
return pdf;
}
/*
A structure that maps the recipient index to the recipient name.
Example: 1 => 'Recipient 1'
*/
const recipientPlaceholders = new Map<number, string>();
for (const placeholder of placeholders) {
const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
recipientPlaceholders.set(recipientIndex, name);
}
/*
Create a list of recipients to create.
Example: [{ email: 'recipient.1@documenso.com', name: 'Recipient 1', role: 'SIGNER', signingOrder: 1 }]
*/
const recipientsToCreate = Array.from(
recipientPlaceholders.entries(),
([recipientIndex, name]) => {
return {
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
};
},
);
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: envelopeId,
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
id: true,
type: true,
secondaryId: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const existingRecipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
},
select: {
id: true,
email: true,
},
});
const existingEmails = new Set(existingRecipients.map((r) => r.email.toLowerCase()));
const recipientsToCreateFiltered = recipientsToCreate.filter(
(r) => !existingEmails.has(r.email.toLowerCase()),
);
let createdRecipients: Pick<Recipient, 'id' | 'email'>[] = existingRecipients;
if (recipientsToCreateFiltered.length > 0) {
if (envelope.type === EnvelopeType.DOCUMENT) {
const { recipients } = await createDocumentRecipients({
userId,
teamId,
id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
createdRecipients = [...existingRecipients, ...recipients];
} else if (envelope.type === EnvelopeType.TEMPLATE) {
const templateId =
envelopeId.type === 'templateId'
? envelopeId.id
: mapSecondaryIdToTemplateId(envelope.secondaryId);
const { recipients } = await createTemplateRecipients({
userId,
teamId,
templateId,
recipients: recipientsToCreateFiltered,
});
createdRecipients = [...existingRecipients, ...recipients];
} else {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid envelope type: ${envelope.type}`,
});
}
}
const fieldsToCreate: FieldToCreate[] = [];
for (const placeholder of placeholders) {
/*
Convert PDF2JSON coordinates to percentage-based coordinates (0-100)
The UI expects positionX and positionY as percentages, not absolute points
PDF2JSON uses relative coordinates: x/pageWidth and y/pageHeight give us the percentage
*/
const xPercent = (placeholder.x / placeholder.pageWidth) * 100;
const yPercent = (placeholder.y / placeholder.pageHeight) * 100;
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
const { email } = extractRecipientPlaceholder(placeholder.recipient);
const normalizedEmail = email.toLowerCase();
const recipient = createdRecipients.find((r) => r.email.toLowerCase() === normalizedEmail);
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Could not find recipient ID for placeholder: ${placeholder.placeholder}`,
});
}
const recipientId = recipient.id;
// Default height percentage if too small (use 2% as a reasonable default)
const finalHeightPercent = heightPercent > 0.01 ? heightPercent : 2;
fieldsToCreate.push({
...placeholder.fieldAndMeta,
envelopeItemId,
recipientId,
pageNumber: placeholder.page,
pageX: xPercent,
pageY: yPercent,
width: widthPercent,
height: finalHeightPercent,
});
}
await createEnvelopeFields({
userId,
teamId,
id: envelopeId,
fields: fieldsToCreate,
requestMetadata,
});
return pdf;
};

View File

@ -1,5 +1,6 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDFDocument } from '@cantoo/pdf-lib';
import { replacePlaceholdersInPDF } from './auto-place-fields';
import { flattenAnnotations } from './flatten-annotations'; import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form'; import { flattenForm, removeOptionalContentGroups } from './flatten-form';
@ -13,6 +14,7 @@ export const normalizePdf = async (pdf: Buffer) => {
removeOptionalContentGroups(pdfDoc); removeOptionalContentGroups(pdfDoc);
await flattenForm(pdfDoc); await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc); flattenAnnotations(pdfDoc);
const pdfWithoutPlaceholders = await replacePlaceholdersInPDF(pdf);
return Buffer.from(await pdfDoc.save()); return pdfWithoutPlaceholders;
}; };

View File

@ -725,13 +725,6 @@ export const createDocumentFromTemplate = async ({
teamId, teamId,
}); });
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_USED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
return envelope; return envelope;
}); });
}; };

View File

@ -1,14 +1,9 @@
import { EnvelopeType, WebhookTriggerEvents } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { type EnvelopeIdOptions } from '../../utils/envelope'; import { type EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteTemplateOptions = { export type DeleteTemplateOptions = {
id: EnvelopeIdOptions; id: EnvelopeIdOptions;
@ -24,18 +19,6 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio
teamId, teamId,
}); });
const templateToDelete = await prisma.envelope.findUniqueOrThrow({
where: envelopeWhereInput,
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_DELETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(templateToDelete)),
userId,
teamId,
});
return await prisma.envelope.delete({ return await prisma.envelope.delete({
where: envelopeWhereInput, where: envelopeWhereInput,
}); });

View File

@ -480,198 +480,5 @@ export const generateSampleWebhookPayload = (
}; };
} }
if (event === WebhookTriggerEvents.DOCUMENT_VIEWED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_DOWNLOADED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.COMPLETED,
completedAt: now,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_REMINDER_SENT) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.RECIPIENT_AUTHENTICATION_FAILED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_CREATED) {
return {
event,
payload: {
...basePayload,
title: 'My Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_UPDATED) {
return {
event,
payload: {
...basePayload,
title: 'My Updated Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_DELETED) {
return {
event,
payload: {
...basePayload,
title: 'Deleted Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_USED) {
return {
event,
payload: {
...basePayload,
title: 'Document from Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`); throw new Error(`Unsupported event type: ${event}`);
}; };

View File

@ -1,17 +0,0 @@
-- 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_VIEWED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RECIPIENT_COMPLETED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_DOWNLOADED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_REMINDER_SENT';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_CREATED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_UPDATED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_DELETED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_USED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECIPIENT_AUTHENTICATION_FAILED';

View File

@ -172,15 +172,6 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED DOCUMENT_COMPLETED
DOCUMENT_REJECTED DOCUMENT_REJECTED
DOCUMENT_CANCELLED DOCUMENT_CANCELLED
DOCUMENT_VIEWED
DOCUMENT_RECIPIENT_COMPLETED
DOCUMENT_DOWNLOADED
DOCUMENT_REMINDER_SENT
TEMPLATE_CREATED
TEMPLATE_UPDATED
TEMPLATE_DELETED
TEMPLATE_USED
RECIPIENT_AUTHENTICATION_FAILED
} }
model Webhook { model Webhook {

View File

@ -1,13 +1,8 @@
import type { DocumentData } from '@prisma/client'; import type { DocumentData } from '@prisma/client';
import { DocumentDataType, EnvelopeType, WebhookTriggerEvents } from '@prisma/client'; import { DocumentDataType, EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '@documenso/lib/types/webhook-payload';
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions'; import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
@ -81,13 +76,6 @@ export const downloadDocumentRoute = authenticatedProcedure
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf'; const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`; const filename = `${baseTitle}${suffix}`;
void triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_DOWNLOADED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
return { return {
downloadUrl: url, downloadUrl: url,
filename, filename,