Compare commits

...

19 Commits

Author SHA1 Message Date
c615e30633 chore: enhance envelope creation with placeholder extraction and removal 2025-11-10 10:08:59 +02:00
358ba2dd6f chore: update envelope handling and improve field positioning in PDF processing 2025-11-07 14:10:46 +02:00
7fa86fe297 Merge branch 'feat/auto-placing-fields' of github.com:documenso/documenso into feat/auto-placing-fields 2025-11-07 10:36:25 +02:00
0b91d33bfb chore: enhance recipient handling and streamline placeholder processing in PDF fields 2025-11-07 10:35:59 +02:00
e010238bcc Merge branch 'main' into feat/auto-placing-fields 2025-11-07 08:57:07 +02:00
498a2be1c7 chore: modularize PDF processing by extracting helper functions 2025-11-06 10:20:04 +02:00
3e84aa632f chore: improve recipient creation with pattern matching and helper function 2025-11-06 09:29:08 +02:00
a08a77e98b refactor: improve variable naming and streamline placeholder extraction logic in PDF processing 2025-11-04 14:18:01 +02:00
13d9ca7a0e chore: some cleanup 2025-11-04 11:16:53 +02:00
d25565b7d0 test: add end-to-end tests for auto-placing fields from PDF placeholders 2025-11-03 16:07:09 +02:00
91421a7d62 Merge branch 'main' into feat/auto-placing-fields 2025-11-03 14:31:28 +02:00
a9f1e39b10 feat: implement envelope item processing and enhance final envelope retrieval 2025-11-03 12:42:47 +02:00
b37748654e feat: enhance recipient placeholder extraction and management in PDF processing 2025-11-03 12:23:11 +02:00
b3ed80d721 feat: integrate placeholder replacement in PDF normalization process 2025-10-31 13:12:23 +02:00
b3cb750470 feat: refactor field metadata handling and enhance field type parsing in PDF processing 2025-10-31 11:41:34 +02:00
1e52493144 feat: fieldMeta parsing 2025-10-30 16:43:54 +02:00
ab95e80987 feat: enhance PDF placeholder handling and recipient management 2025-10-30 13:52:18 +02:00
1780a5c262 Merge branch 'main' into feat/auto-placing-fields 2025-10-29 14:29:04 +02:00
cb9bf407f7 feat: autoplace fields from placeholders 2025-10-28 13:50:06 +02:00
9 changed files with 841 additions and 3 deletions

13
package-lock.json generated
View File

@ -19,6 +19,7 @@
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "^3.25.76"
@ -27488,6 +27489,18 @@
"dev": true,
"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": {
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",

View File

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

View File

@ -0,0 +1,129 @@
import { type Page, expect, test } from '@playwright/test';
import path from 'path';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
__dirname,
'../../../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('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
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,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
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,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
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');
});
});
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();
});
});

Binary file not shown.

View File

@ -10,6 +10,11 @@ import {
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
extractPlaceholdersFromPDF,
insertFieldsFromPlaceholdersInPDF,
removePlaceholdersFromPDF,
} from '@documenso/lib/server-only/pdf/auto-place-fields';
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 type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -34,6 +39,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
@ -256,7 +262,7 @@ export const createEnvelope = async ({
? await incrementDocumentId().then((v) => v.formattedDocumentId)
: 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({
data: {
id: prefixedId('envelope'),
@ -376,8 +382,12 @@ export const createEnvelope = async ({
recipients: true,
fields: true,
folder: true,
envelopeItems: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
@ -413,4 +423,74 @@ export const createEnvelope = async ({
return createdEnvelope;
});
for (const envelopeItem of createdEnvelope.envelopeItems) {
const buffer = await getFileServerSide(envelopeItem.documentData);
const pdfToProcess = Buffer.from(buffer);
const envelopeOptions: EnvelopeIdOptions = {
type: 'envelopeId',
id: createdEnvelope.id,
};
const placeholders = await extractPlaceholdersFromPDF(pdfToProcess);
if (placeholders.length > 0) {
const pdfWithoutPlaceholders = await removePlaceholdersFromPDF(pdfToProcess);
await insertFieldsFromPlaceholdersInPDF(
pdfWithoutPlaceholders,
userId,
teamId,
envelopeOptions,
requestMetadata,
envelopeItem.id,
createdEnvelope.recipients,
);
const titleToUse = envelopeItem.title || title;
const fileName = titleToUse.endsWith('.pdf') ? titleToUse : `${titleToUse}.pdf`;
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfWithoutPlaceholders),
});
await prisma.envelopeItem.update({
where: {
id: envelopeItem.id,
},
data: {
documentDataId: newDocumentData.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

@ -0,0 +1,347 @@
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
import type { Recipient } from '@prisma/client';
import PDFParser from 'pdf2json';
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 { 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 { prisma } from '@documenso/prisma';
import { getPageSize } from './get-page-size';
import {
determineRecipientsForPlaceholders,
extractRecipientPlaceholder,
findRecipientByPlaceholder,
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
} from './helpers';
const PLACEHOLDER_REGEX = /{{([^}]+)}}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const WIDTH_ADJUSTMENT_FACTOR = 0.1;
const MIN_HEIGHT_THRESHOLD = 0.01;
type TextPosition = {
text: string;
x: number;
y: number;
w: number;
};
type CharIndexMapping = {
textPositionIndex: 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;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
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)
*/
let pageText = '';
const textPositions: TextPosition[] = [];
const charIndexMappings: CharIndexMapping[] = [];
page.Texts.forEach((text) => {
/*
R is an array of objects containing each character, its position and styling information.
The decodedText stores the characters, 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 each character in the decodedText, we store its position in the textPositions array.
This allows us to quickly find the position of a character in the textPositions array by its index.
*/
for (let i = 0; i < decodedText.length; i++) {
charIndexMappings.push({
textPositionIndex: textPositions.length,
});
}
pageText += decodedText;
textPositions.push({
text: decodedText,
x: text.x,
y: text.y,
w: text.w || 0,
});
});
const placeholderMatches = pageText.matchAll(PLACEHOLDER_REGEX);
/*
A placeholder match has the following format:
[
'{{fieldType,recipient,fieldMeta}}',
'fieldType,recipient,fieldMeta',
'index: <number>',
'input: <pdf-text>'
]
*/
for (const placeholderMatch of placeholderMatches) {
const placeholder = placeholderMatch[0];
const placeholderData = placeholderMatch[1].split(',').map((property) => property.trim());
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
const rawFieldMeta = Object.fromEntries(
fieldMetaData.map((property) => property.split('=')),
);
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
Find the position of where the placeholder starts and ends in the text.
Then find the position of the characters in the textPositions array.
This allows us to quickly find the position of a character in the textPositions array by its index.
*/
const placeholderEndCharIndex = placeholderMatch.index + placeholder.length;
/*
Get the index of the placeholder's first and last character in the textPositions array.
Used to retrieve the character information from the textPositions array.
Example:
startTextPosIndex - 1
endTextPosIndex - 40
*/
const startTextPosIndex = charIndexMappings[placeholderMatch.index].textPositionIndex;
const endTextPosIndex = charIndexMappings[placeholderEndCharIndex - 1].textPositionIndex;
/*
Get the placeholder's first and last character information from the textPositions array.
Example:
placeholderStart = { text: '{', x: 100, y: 100, w: 100 }
placeholderEnd = { text: '}', x: 200, y: 100, w: 100 }
*/
const placeholderStart = textPositions[startTextPosIndex];
const placeholderEnd = textPositions[endTextPosIndex];
const width =
placeholderEnd.x + placeholderEnd.w * WIDTH_ADJUSTMENT_FACTOR - placeholderStart.x;
placeholders.push({
placeholder,
recipient,
fieldAndMeta,
page: pageIndex + 1,
x: placeholderStart.x,
y: placeholderStart.y,
width,
height: 1,
pageWidth: page.Width,
pageHeight: page.Height,
});
}
});
resolve(placeholders);
});
parser.parseBuffer(pdf);
});
};
export const removePlaceholdersFromPDF = 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)
*/
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);
};
export const insertFieldsFromPlaceholdersInPDF = async (
pdf: Buffer,
userId: number,
teamId: number,
envelopeId: EnvelopeIdOptions,
requestMetadata: ApiRequestMetadata,
envelopeItemId?: string,
recipients?: Pick<Recipient, 'id' | 'email'>[],
): 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);
}
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 createdRecipients = await determineRecipientsForPlaceholders(
recipients,
recipientPlaceholders,
envelope,
userId,
teamId,
requestMetadata,
);
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 recipient = findRecipientByPlaceholder(
placeholder.recipient,
placeholder.placeholder,
recipients,
createdRecipients,
);
// Default height percentage if too small (use 2% as a reasonable default)
const finalHeightPercent =
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
fieldsToCreate.push({
...placeholder.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: placeholder.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
});
}
await createEnvelopeFields({
userId,
teamId,
id: envelopeId,
fields: fieldsToCreate,
requestMetadata,
});
return pdf;
};

View File

@ -0,0 +1,266 @@
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 { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-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,
};
};
/*
Finds a recipient based on a placeholder reference.
If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.).
Otherwise, uses email-based matching from createdRecipients.
*/
export const findRecipientByPlaceholder = (
recipientPlaceholder: string,
placeholder: string,
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
createdRecipients: Pick<Recipient, 'id' | 'email'>[],
): Pick<Recipient, 'id' | '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(recipientPlaceholder);
const recipientArrayIndex = recipientIndex - 1;
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
});
}
return recipients[recipientArrayIndex];
}
/*
Use email-based matching for placeholder recipients.
*/
const { email } = extractRecipientPlaceholder(recipientPlaceholder);
const recipient = createdRecipients.find((r) => r.email === email);
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Could not find recipient ID for placeholder: ${placeholder}`,
});
}
return recipient;
};
/*
Determines the recipients to use for field creation.
If recipients are provided, uses them directly.
Otherwise, creates recipients from placeholders.
*/
export const determineRecipientsForPlaceholders = async (
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
recipientPlaceholders: Map<number, string>,
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
userId: number,
teamId: number,
requestMetadata: ApiRequestMetadata,
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
if (recipients && recipients.length > 0) {
return recipients;
}
return createRecipientsFromPlaceholders(
recipientPlaceholders,
envelope,
userId,
teamId,
requestMetadata,
);
};
export const createRecipientsFromPlaceholders = async (
recipientPlaceholders: Map<number, string>,
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
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 envelopeId: EnvelopeIdOptions = {
type: 'envelopeId',
id: envelope.id,
};
const { recipients } = await createEnvelopeRecipients({
userId,
teamId,
id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
return recipients;
})
.with(EnvelopeType.TEMPLATE, async () => {
const templateId = mapSecondaryIdToTemplateId(envelope.secondaryId ?? '');
const envelopeId: EnvelopeIdOptions = {
type: 'templateId',
id: templateId,
};
const { recipients } = await createEnvelopeRecipients({
userId,
teamId,
id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
return recipients;
})
.otherwise(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid envelope type: ${envelope.type}`,
});
});
return [...existingRecipients, ...newRecipients];
};

View File

@ -23,5 +23,7 @@ export const normalizePdf = async (pdf: Buffer) => {
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
return Buffer.from(await pdfDoc.save());
const normalizedPdfBytes = await pdfDoc.save();
return Buffer.from(normalizedPdfBytes);
};