Compare commits

..

10 Commits

Author SHA1 Message Date
0977c16e33 v2.0.5 2025-11-08 16:03:59 +11:00
88d5a636c3 fix: show legacy ids on template and document view page (#2153)
<img width="557" height="455" alt="image"
src="https://github.com/user-attachments/assets/7b669f4a-c6c5-4fdc-bf10-da0def7b0b3f"
/>
2025-11-08 16:03:26 +11:00
1e6292b1d9 v2.0.4 2025-11-08 13:58:11 +11:00
d65866156d fix: remove parallel steps (#2152) 2025-11-08 13:57:26 +11:00
fe8915162f v2.0.3 2025-11-08 12:53:50 +11:00
37a2634aca feat: support optimizeParallelism for inngest jobs (#2151) 2025-11-08 12:53:13 +11:00
eff7d90f43 v2.0.2 2025-11-08 00:48:31 +11:00
db5524f8ce fix: resolve issue with sealing task on inngest (#2146)
Currently on inngest the sealing task fails during decoration stating
that it can not find the step "xxx"

My running theory is that this was due to it being a
Promise.all(map(...)) even though that isn't explicitly disallowed.

This change turns it into a for loop collecting promises to be awaited
after the fact.

Local inngest testing looks promising.
2025-11-08 00:48:13 +11:00
3d539b20ad v2.0.1 2025-11-07 23:42:03 +11:00
48626b9169 fix: support utf8 filenames download (#2145) 2025-11-07 23:41:31 +11:00
15 changed files with 93 additions and 837 deletions

View File

@ -7,6 +7,7 @@ import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
export type DocumentPageViewInformationProps = {
userId: number;
@ -40,6 +41,10 @@ export const DocumentPageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
{
description: msg`Document ID (Legacy)`,
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, envelope, userId]);

View File

@ -7,11 +7,13 @@ import type { User } from '@prisma/client';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
export type TemplatePageViewInformationProps = {
userId: number;
template: {
userId: number;
secondaryId: string;
createdAt: Date;
updatedAt: Date;
user: Pick<User, 'id' | 'name' | 'email'>;
@ -43,6 +45,10 @@ export const TemplatePageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
{
description: msg`Template ID (Legacy)`,
value: mapSecondaryIdToTemplateId(template.secondaryId),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]);

View File

@ -41,6 +41,7 @@
"@simplewebauthn/server": "^9.0.3",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"content-disposition": "^0.5.4",
"framer-motion": "^10.12.8",
"hono": "4.7.0",
"hono-rate-limiter": "^0.4.2",
@ -87,6 +88,7 @@
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@simplewebauthn/types": "^9.0.1",
"@types/content-disposition": "^0.5.9",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "^20",
@ -104,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.0.0"
"version": "2.0.5"
}

View File

@ -1,4 +1,5 @@
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { sha256 } from '@documenso/lib/universal/crypto';
@ -34,7 +35,7 @@ export const handleEnvelopeItemFileRequest = async ({
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
if (c.req.header('If-None-Match') === etag) {
if (c.req.header('If-None-Match') === etag && !isDownload) {
return c.body(null, 304);
}
@ -58,7 +59,6 @@ export const handleEnvelopeItemFileRequest = async ({
if (status === DocumentStatus.COMPLETED) {
c.header('Cache-Control', 'public, max-age=31536000, immutable');
} else {
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
}
}
@ -69,7 +69,7 @@ export const handleEnvelopeItemFileRequest = async ({
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
c.header('Content-Disposition', `attachment; filename="${filename}"`);
c.header('Content-Disposition', contentDisposition(filename));
// For downloads, prevent caching to ensure fresh data
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');

28
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.0.0",
"version": "2.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.0.0",
"version": "2.0.5",
"workspaces": [
"apps/*",
"packages/*"
@ -19,7 +19,6 @@
"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"
@ -101,7 +100,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.0.0",
"version": "2.0.5",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",
@ -130,6 +129,7 @@
"@simplewebauthn/server": "^9.0.3",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"content-disposition": "^0.5.4",
"framer-motion": "^10.12.8",
"hono": "4.7.0",
"hono-rate-limiter": "^0.4.2",
@ -176,6 +176,7 @@
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@simplewebauthn/types": "^9.0.1",
"@types/content-disposition": "^0.5.9",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "^20",
@ -12316,6 +12317,13 @@
"@types/node": "*"
}
},
"node_modules/@types/content-disposition": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cross-spawn": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz",
@ -27489,18 +27497,6 @@
"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

@ -1,6 +1,6 @@
{
"private": true,
"version": "2.0.0",
"version": "2.0.5",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -85,7 +85,6 @@
"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

@ -1,129 +0,0 @@
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();
});
});

View File

@ -32,6 +32,7 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
name: string;
version: string;
enabled?: boolean;
optimizeParallelism?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;

View File

@ -40,6 +40,7 @@ export class InngestJobProvider extends BaseJobProvider {
{
id: job.id,
name: job.name,
optimizeParallelism: job.optimizeParallelism ?? false,
},
{
event: job.trigger.name,

View File

@ -189,29 +189,65 @@ export const run = async ({
settings,
});
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
}),
),
);
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
// decoratePromises.push(task);
// }
// const newDocumentData = await Promise.all(decoratePromises);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
});
newDocumentData.push(result);
}
const postHog = PostHogServerClient();

View File

@ -18,6 +18,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
name: 'Seal Document',
version: '1.0.0',
optimizeParallelism: true,
trigger: {
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,

View File

@ -10,7 +10,6 @@ import {
} from '@prisma/client';
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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -257,7 +256,7 @@ export const createEnvelope = async ({
? await incrementDocumentId().then((v) => v.formattedDocumentId)
: await incrementTemplateId().then((v) => v.formattedTemplateId);
const createdEnvelope = await prisma.$transaction(async (tx) => {
return await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
@ -377,12 +376,8 @@ export const createEnvelope = async ({
recipients: true,
fields: true,
folder: true,
envelopeItems: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
@ -418,51 +413,4 @@ export const createEnvelope = async ({
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,347 +0,0 @@
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;
pageNumber: number;
pageX: number;
pageY: 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,
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,261 +0,0 @@
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,
};
};
/*
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 createDocumentRecipients({
userId,
teamId,
id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
return recipients;
})
.with(EnvelopeType.TEMPLATE, async () => {
const templateId = 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];
};

View File

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