mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 07:43:16 +10:00
Compare commits
2 Commits
a08a77e98b
...
498a2be1c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 498a2be1c7 | |||
| 3e84aa632f |
@ -1,33 +1,50 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
const PLACEHOLDER_PDF_PATH = path.join(
|
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../../../assets/project-proposal-single-recipient.pdf',
|
'../../../assets/project-proposal-single-recipient.pdf',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../../assets/project-proposal-multiple-fields-and-recipients.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupUserAndSignIn = async (page: Page) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, team };
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadPdfAndContinue = async (page: Page, pdfPath: string, continueClicks: number = 1) => {
|
||||||
|
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||||
|
await fileInput.waitFor({ state: 'attached' });
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
for (let i = 0; i < continueClicks; i++) {
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
test.describe('PDF Placeholders with single recipient', () => {
|
test.describe('PDF Placeholders with single recipient', () => {
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, team } = await seedUser();
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
|
||||||
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.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
|
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
|
||||||
@ -37,22 +54,8 @@ test.describe('PDF Placeholders with single recipient', () => {
|
|||||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, team } = await seedUser();
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
||||||
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.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
@ -65,22 +68,8 @@ test.describe('PDF Placeholders with single recipient', () => {
|
|||||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, team } = await seedUser();
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
||||||
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.getByText('Text').nth(1).click();
|
||||||
await page.getByRole('button', { name: 'Advanced settings' }).click();
|
await page.getByRole('button', { name: 'Advanced settings' }).click();
|
||||||
@ -96,3 +85,45 @@ test.describe('PDF Placeholders with single recipient', () => {
|
|||||||
await expect(page.getByRole('combobox')).toHaveText('Right');
|
await expect(page.getByRole('combobox')).toHaveText('Right');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('PDF Placeholders with multiple recipients', () => {
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 1);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||||
|
'recipient.1@documenso.com',
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
||||||
|
'recipient.2@documenso.com',
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Name').nth(1)).toHaveValue('Recipient 2');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
||||||
|
'recipient.3@documenso.com',
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Name').nth(2)).toHaveValue('Recipient 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 2);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(1)).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(2)).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="EMAIL"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="EMAIL"]').nth(1)).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="NUMBER"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,21 +1,22 @@
|
|||||||
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
||||||
import type { Recipient } from '@prisma/client';
|
import type { Recipient } from '@prisma/client';
|
||||||
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
|
|
||||||
import PDFParser from 'pdf2json';
|
import PDFParser from 'pdf2json';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
|
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 TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
||||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getPageSize } from './get-page-size';
|
import { getPageSize } from './get-page-size';
|
||||||
|
import {
|
||||||
|
createRecipientsFromPlaceholders,
|
||||||
|
extractRecipientPlaceholder,
|
||||||
|
parseFieldMetaFromPlaceholder,
|
||||||
|
parseFieldTypeFromPlaceholder,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
type TextPosition = {
|
type TextPosition = {
|
||||||
text: string;
|
text: string;
|
||||||
@ -51,12 +52,6 @@ type FieldToCreate = TFieldAndMeta & {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecipientPlaceholderInfo = {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
recipientIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Questions for later:
|
Questions for later:
|
||||||
- Does it handle multi-page PDFs? ✅ YES! ✅
|
- Does it handle multi-page PDFs? ✅ YES! ✅
|
||||||
@ -67,86 +62,6 @@ type RecipientPlaceholderInfo = {
|
|||||||
- Need to handle envelopes with multiple items. ✅
|
- 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 [property, value] of rawFieldMetaEntries) {
|
|
||||||
if (property === 'readOnly' || property === 'required') {
|
|
||||||
parsedFieldMeta[property] = value === 'true';
|
|
||||||
} else if (
|
|
||||||
property === 'fontSize' ||
|
|
||||||
property === 'maxValue' ||
|
|
||||||
property === 'minValue' ||
|
|
||||||
property === 'characterLimit'
|
|
||||||
) {
|
|
||||||
const numValue = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isNaN(numValue)) {
|
|
||||||
parsedFieldMeta[property] = numValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsedFieldMeta[property] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedFieldMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const parser = new PDFParser(null, true);
|
const parser = new PDFParser(null, true);
|
||||||
@ -221,8 +136,8 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
|||||||
fieldMetaData.map((property) => property.split('=')),
|
fieldMetaData.map((property) => property.split('=')),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fieldType = parseFieldType(fieldTypeString);
|
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
|
||||||
const parsedFieldMeta = parseFieldMeta(rawFieldMeta, fieldType);
|
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||||
|
|
||||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
||||||
type: fieldType,
|
type: fieldType,
|
||||||
@ -288,7 +203,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> => {
|
export const removePlaceholdersFromPDF = async (pdf: Buffer): Promise<Buffer> => {
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
||||||
@ -334,24 +249,6 @@ export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> =>
|
|||||||
return Buffer.from(modifiedPdfBytes);
|
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 (
|
export const insertFieldsFromPlaceholdersInPDF = async (
|
||||||
pdf: Buffer,
|
pdf: Buffer,
|
||||||
userId: number,
|
userId: number,
|
||||||
@ -359,6 +256,7 @@ export const insertFieldsFromPlaceholdersInPDF = async (
|
|||||||
envelopeId: EnvelopeIdOptions,
|
envelopeId: EnvelopeIdOptions,
|
||||||
requestMetadata: ApiRequestMetadata,
|
requestMetadata: ApiRequestMetadata,
|
||||||
envelopeItemId?: string,
|
envelopeItemId?: string,
|
||||||
|
recipients?: Pick<Recipient, 'id' | 'email'>[],
|
||||||
): Promise<Buffer> => {
|
): Promise<Buffer> => {
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||||
|
|
||||||
@ -378,22 +276,6 @@ export const insertFieldsFromPlaceholdersInPDF = async (
|
|||||||
recipientPlaceholders.set(recipientIndex, name);
|
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({
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
id: envelopeId,
|
id: envelopeId,
|
||||||
userId,
|
userId,
|
||||||
@ -416,53 +298,19 @@ export const insertFieldsFromPlaceholdersInPDF = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingRecipients = await prisma.recipient.findMany({
|
let createdRecipients: Pick<Recipient, 'id' | 'email'>[];
|
||||||
where: {
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingEmails = new Set(existingRecipients.map((r) => r.email));
|
if (recipients && recipients.length > 0) {
|
||||||
const recipientsToCreateFiltered = recipientsToCreate.filter(
|
createdRecipients = recipients;
|
||||||
(recipient) => !existingEmails.has(recipient.email),
|
} else {
|
||||||
);
|
createdRecipients = await createRecipientsFromPlaceholders(
|
||||||
|
recipientPlaceholders,
|
||||||
let createdRecipients: Pick<Recipient, 'id' | 'email'>[] = existingRecipients;
|
envelope,
|
||||||
|
envelopeId,
|
||||||
if (recipientsToCreateFiltered.length > 0) {
|
userId,
|
||||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
teamId,
|
||||||
const { recipients } = await createDocumentRecipients({
|
requestMetadata,
|
||||||
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[] = [];
|
const fieldsToCreate: FieldToCreate[] = [];
|
||||||
@ -479,8 +327,30 @@ export const insertFieldsFromPlaceholdersInPDF = async (
|
|||||||
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
|
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
|
||||||
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
|
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
|
||||||
|
|
||||||
const { email } = extractRecipientPlaceholder(placeholder.recipient);
|
let recipient: Pick<Recipient, 'id' | 'email'> | undefined;
|
||||||
const recipient = createdRecipients.find((r) => r.email === email);
|
|
||||||
|
if (recipients && recipients.length > 0) {
|
||||||
|
/*
|
||||||
|
Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc.
|
||||||
|
recipientIndex is 1-based, so we subtract 1 to get the array index.
|
||||||
|
*/
|
||||||
|
const { recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
|
||||||
|
const recipientArrayIndex = recipientIndex - 1;
|
||||||
|
|
||||||
|
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Recipient placeholder ${placeholder.recipient} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient = recipients[recipientArrayIndex];
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
Use email-based matching for placeholder recipients.
|
||||||
|
*/
|
||||||
|
const { email } = extractRecipientPlaceholder(placeholder.recipient);
|
||||||
|
recipient = createdRecipients.find((r) => r.email === email);
|
||||||
|
}
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
|||||||
191
packages/lib/server-only/pdf/helpers.ts
Normal file
191
packages/lib/server-only/pdf/helpers.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { type Envelope, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { createDocumentRecipients } from '@documenso/lib/server-only/recipient/create-document-recipients';
|
||||||
|
import { createTemplateRecipients } from '@documenso/lib/server-only/recipient/create-template-recipients';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
|
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
type RecipientPlaceholderInfo = {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
recipientIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export const parseFieldTypeFromPlaceholder = (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).
|
||||||
|
*/
|
||||||
|
export const parseFieldMetaFromPlaceholder = (
|
||||||
|
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 [property, value] of rawFieldMetaEntries) {
|
||||||
|
if (property === 'readOnly' || property === 'required') {
|
||||||
|
parsedFieldMeta[property] = value === 'true';
|
||||||
|
} else if (
|
||||||
|
property === 'fontSize' ||
|
||||||
|
property === 'maxValue' ||
|
||||||
|
property === 'minValue' ||
|
||||||
|
property === 'characterLimit'
|
||||||
|
) {
|
||||||
|
const numValue = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isNaN(numValue)) {
|
||||||
|
parsedFieldMeta[property] = numValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedFieldMeta[property] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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 createRecipientsFromPlaceholders = async (
|
||||||
|
recipientPlaceholders: Map<number, string>,
|
||||||
|
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
|
||||||
|
envelopeId: EnvelopeIdOptions,
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requestMetadata: ApiRequestMetadata,
|
||||||
|
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
|
||||||
|
const recipientsToCreate = Array.from(
|
||||||
|
recipientPlaceholders.entries(),
|
||||||
|
([recipientIndex, name]) => {
|
||||||
|
return {
|
||||||
|
email: `recipient.${recipientIndex}@documenso.com`,
|
||||||
|
name,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: recipientIndex,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingRecipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingEmails = new Set(existingRecipients.map((r) => r.email));
|
||||||
|
const recipientsToCreateFiltered = recipientsToCreate.filter(
|
||||||
|
(recipient) => !existingEmails.has(recipient.email),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recipientsToCreateFiltered.length === 0) {
|
||||||
|
return existingRecipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecipients = await match(envelope.type)
|
||||||
|
.with(EnvelopeType.DOCUMENT, async () => {
|
||||||
|
const { recipients } = await createDocumentRecipients({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
id: envelopeId,
|
||||||
|
recipients: recipientsToCreateFiltered,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients;
|
||||||
|
})
|
||||||
|
.with(EnvelopeType.TEMPLATE, async () => {
|
||||||
|
const templateId =
|
||||||
|
envelopeId.type === 'templateId'
|
||||||
|
? envelopeId.id
|
||||||
|
: mapSecondaryIdToTemplateId(envelope.secondaryId ?? '');
|
||||||
|
|
||||||
|
const { recipients } = await createTemplateRecipients({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
templateId,
|
||||||
|
recipients: recipientsToCreateFiltered,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients;
|
||||||
|
})
|
||||||
|
.otherwise(() => {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid envelope type: ${envelope.type}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...existingRecipients, ...newRecipients];
|
||||||
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||||
|
|
||||||
import { replacePlaceholdersInPDF } from './auto-place-fields';
|
import { removePlaceholdersFromPDF } 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';
|
||||||
|
|
||||||
@ -14,7 +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);
|
const pdfWithoutPlaceholders = await removePlaceholdersFromPDF(pdf);
|
||||||
|
|
||||||
return pdfWithoutPlaceholders;
|
return pdfWithoutPlaceholders;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user