feat: autoplace fields from placeholders (#2111)

This PR introduces automatic detection and placement of fields and
recipients based on PDF placeholders.

The placeholders have the following structure:
- `{{fieldType,recipientPosition,fieldMeta}}` 
- `{{text,r1,required=true,textAlign=right,fontSize=50}}`

When the user uploads a PDF document containing such placeholders, they
get converted automatically to Documenso fields and assigned to
recipients.
This commit is contained in:
Catalin Pit
2026-01-29 04:13:45 +02:00
committed by GitHub
parent d77f81163b
commit d18dcb4d60
22 changed files with 2045 additions and 50 deletions
@@ -0,0 +1,453 @@
import { PDF, StandardFonts } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement');
test.describe.configure({ mode: 'parallel' });
test.describe('Placeholder-based field creation', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
const createEnvelopeWithPdf = async (
request: APIRequestContext,
pdfFilename: string,
): Promise<TCreateEnvelopeResponse> => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Placeholder Fields Test',
} satisfies TCreateEnvelopePayload),
);
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
const createEnvelopeItemsWithPdf = async (
request: APIRequestContext,
envelopeId: string,
pdfFilename: string,
) => {
const pdfPath = path.join(FIXTURES_DIR, pdfFilename);
const pdfData = fs.readFileSync(pdfPath);
const formData = new FormData();
formData.append('payload', JSON.stringify({ envelopeId }));
formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
const addRecipient = async (request: APIRequestContext, envelopeId: string) => {
const payload: TCreateEnvelopeRecipientsRequest = {
envelopeId,
data: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: payload,
});
expect(res.ok()).toBeTruthy();
};
const addRecipients = async (
request: APIRequestContext,
envelopeId: string,
recipients: TCreateEnvelopeRecipientsRequest['data'],
) => {
const payload: TCreateEnvelopeRecipientsRequest = {
envelopeId,
data: recipients,
};
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: payload,
});
expect(res.ok()).toBeTruthy();
};
const getEnvelope = async (
request: APIRequestContext,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBeTruthy();
return res.json();
};
/**
* Create a PDF with the same placeholder appearing multiple times at different locations.
*/
const createPdfWithDuplicatePlaceholders = async (): Promise<Buffer> => {
const pdf = PDF.create();
const page = pdf.addPage({ size: 'letter' });
// Draw the same placeholder text at three different Y positions.
page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 });
page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 });
page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 });
const bytes = await pdf.save();
return Buffer.from(bytes);
};
const createEnvelopeWithPdfBuffer = async (
request: APIRequestContext,
pdfBuffer: Buffer,
filename: string,
): Promise<TCreateEnvelopeResponse> => {
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Placeholder Fields Test',
} satisfies TCreateEnvelopePayload),
);
formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return res.json();
};
test('should create a field at a placeholder location', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.SIGNATURE,
placeholder: '{{signature}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields).toHaveLength(1);
expect(fields[0].type).toBe(FieldType.SIGNATURE);
// Verify the field has non-zero position/dimensions resolved from the placeholder.
expect(fields[0].positionX.toNumber()).toBeGreaterThan(0);
expect(fields[0].positionY.toNumber()).toBeGreaterThan(0);
expect(fields[0].width.toNumber()).toBeGreaterThan(0);
expect(fields[0].height.toNumber()).toBeGreaterThan(0);
});
test('should override width and height when provided', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.NAME,
placeholder: '{{name}}',
width: 30,
height: 5,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields).toHaveLength(1);
expect(fields[0].width.toNumber()).toBeCloseTo(30, 1);
expect(fields[0].height.toNumber()).toBeCloseTo(5, 1);
});
test('should fail when placeholder text is not found in the PDF', async ({ request }) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.TEXT,
placeholder: '{{nonexistent}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeFalsy();
});
test('should create fields using a mix of coordinate and placeholder positioning', async ({
request,
}) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.SIGNATURE,
placeholder: '{{signature}}',
},
{
recipientId,
type: FieldType.DATE,
page: 1,
positionX: 10,
positionY: 20,
width: 15,
height: 3,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
orderBy: { type: 'asc' },
});
expect(fields).toHaveLength(2);
const dateField = fields.find((f) => f.type === FieldType.DATE);
const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE);
expect(dateField).toBeDefined();
expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1);
expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1);
expect(signatureField).toBeDefined();
expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0);
});
test('should create a field only at first occurrence by default', async ({ request }) => {
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.INITIALS,
placeholder: '{{initials}}',
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
// Should only create one field (first occurrence).
expect(fields).toHaveLength(1);
expect(fields[0].type).toBe(FieldType.INITIALS);
});
test('should create fields at all occurrences when matchAll is true', async ({ request }) => {
const pdfBuffer = await createPdfWithDuplicatePlaceholders();
const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf');
await addRecipient(request, envelope.id);
const envelopeData = await getEnvelope(request, envelope.id);
const recipientId = envelopeData.recipients[0].id;
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
data: [
{
recipientId,
type: FieldType.INITIALS,
placeholder: '{{initials}}',
matchAll: true,
},
],
},
});
expect(createFieldsRes.ok()).toBeTruthy();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
orderBy: { positionY: 'asc' },
});
// Should create three fields (one for each occurrence).
expect(fields).toHaveLength(3);
// All should be INITIALS type.
expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true);
// Verify they're at different Y positions.
const yPositions = fields.map((f) => f.positionY.toNumber());
const uniqueYPositions = new Set(yPositions);
expect(uniqueYPositions.size).toBe(3);
});
test('should map placeholder recipients by signing order when adding items', async ({
request,
}) => {
const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf');
await addRecipients(request, envelope.id, [
{
email: 'second.recipient@documenso.com',
name: 'Second Recipient',
role: RecipientRole.SIGNER,
signingOrder: 2,
accessAuth: [],
actionAuth: [],
},
{
email: 'first.recipient@documenso.com',
name: 'First Recipient',
role: RecipientRole.SIGNER,
signingOrder: 1,
accessAuth: [],
actionAuth: [],
},
]);
await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf');
const recipients = await prisma.recipient.findMany({
where: { envelopeId: envelope.id },
});
const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1);
expect(firstRecipient).toBeDefined();
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
expect(fields.length).toBeGreaterThan(0);
expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true);
});
});
@@ -0,0 +1,307 @@
import { type Page, expect, test } from '@playwright/test';
import path from 'path';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement');
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
FIXTURES_DIR,
'project-proposal-single-recipient.pdf',
);
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
FIXTURES_DIR,
'project-proposal-multiple-fields-and-recipients.pdf',
);
const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf');
const INVALID_FIELD_TYPE_PDF_PATH = path.join(FIXTURES_DIR, 'invalid-field-type.pdf');
const FIELD_TYPE_ONLY_PDF_PATH = path.join(FIXTURES_DIR, 'field-type-only.pdf');
const setTeamDefaultRecipients = async (
teamId: number,
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data: {
defaultRecipients,
},
});
};
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 uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) => {
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(pdfPath);
// Wait for redirect to v2 envelope editor.
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Extract envelope ID from URL.
const urlParts = page.url().split('/');
const envelopeId = urlParts.find((part) => part.startsWith('envelope_'));
if (!envelopeId) {
throw new Error('Could not extract envelope ID from URL');
}
return envelopeId;
};
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({
page,
}) => {
const { user, team } = await seedUser();
await setTeamDefaultRecipients(team.id, [
{
email: user.email,
name: user.name || user.email,
role: RecipientRole.CC,
},
]);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
const placeholderRecipient = recipients.find(
(recipient) => recipient.email === 'recipient.1@documenso.com',
);
const defaultRecipient = recipients.find((recipient) => recipient.email === user.email);
expect(placeholderRecipient).toBeDefined();
expect(defaultRecipient).toBeDefined();
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields.length).toBeGreaterThan(0);
expect(fields.every((field) => field.recipientId === placeholderRecipient!.id)).toBe(true);
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// V2 editor shows recipients on the upload page under "Recipients" heading.
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
});
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// V2 editor renders fields on a Konva canvas, so we verify via the database.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(['EMAIL', 'NAME', 'SIGNATURE', 'TEXT'].sort());
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH);
// Verify field metadata was correctly parsed from the placeholder.
await expect(async () => {
const textField = await prisma.field.findFirst({
where: { envelopeId, type: 'TEXT' },
});
expect(textField).toBeDefined();
expect(textField!.fieldMeta).toBeDefined();
const meta = textField!.fieldMeta as Record<string, unknown>;
expect(meta.required).toBe(true);
expect(meta.textAlign).toBe('right');
}).toPass();
});
});
test.describe('PDF Placeholders with multiple recipients', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
// V2 editor shows recipients on the upload page.
await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
'recipient.2@documenso.com',
);
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
'recipient.3@documenso.com',
);
// Verify recipients via the database for name validation since the v2 editor
// only shows the "Name" label on the first recipient row.
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
orderBy: { signingOrder: 'asc' },
});
expect(recipients).toHaveLength(3);
expect(recipients[0].name).toBe('Recipient 1');
expect(recipients[1].name).toBe('Recipient 2');
expect(recipients[2].name).toBe('Recipient 3');
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH);
// V2 editor renders fields on a Konva canvas, so we verify via the database.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(
['SIGNATURE', 'SIGNATURE', 'SIGNATURE', 'EMAIL', 'EMAIL', 'NAME', 'TEXT', 'NUMBER'].sort(),
);
}).toPass();
});
});
test.describe('PDF Placeholders without recipient identifier', () => {
test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH);
// Placeholders like {{signature}}, {{name}}, {{email}} have no recipient
// identifier and should be skipped entirely. No fields or auto-created
// recipients should exist.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields).toHaveLength(0);
}).toPass();
});
test('[AUTO_PLACING_FIELDS]: should skip a bare field type placeholder', async ({ page }) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, FIELD_TYPE_ONLY_PDF_PATH);
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
expect(fields).toHaveLength(0);
}).toPass();
});
});
test.describe('PDF Placeholders with invalid field types', () => {
test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({
page,
}) => {
const { team } = await setupUserAndSignIn(page);
const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH);
// Only the valid placeholders (signature,r1 and email,r2) should create fields.
// The invalid ones (bogus,r1 and foobar,r2) should be skipped.
await expect(async () => {
const fields = await prisma.field.findMany({
where: { envelopeId },
});
const fieldTypes = fields.map((f) => f.type).sort();
expect(fieldTypes).toEqual(['EMAIL', 'SIGNATURE'].sort());
}).toPass();
// Both valid recipients should still be created.
await expect(async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
orderBy: { signingOrder: 'asc' },
});
expect(recipients).toHaveLength(2);
}).toPass();
});
});
@@ -10,6 +10,9 @@ import {
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -68,7 +71,12 @@ export type CreateEnvelopeOptions = {
type: EnvelopeType;
title: string;
externalId?: string;
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
}[];
formValues?: TDocumentFormValues;
userTimezone?: string;
@@ -164,8 +172,7 @@ export const createEnvelope = async ({
});
}
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
let envelopeItems = data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
@@ -298,7 +305,7 @@ export const createEnvelope = async ({
const delegatedOwner = await getValidatedDelegatedOwner();
const envelopeOwnerId = delegatedOwner?.id ?? userId;
return await prisma.$transaction(async (tx) => {
const createdEnvelope = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -423,6 +430,124 @@ export const createEnvelope = async ({
}),
);
// Create fields from PDF placeholders (extracted at upload time).
const itemsWithPlaceholders = envelopeItems.filter(
(item) => item.placeholders && item.placeholders.length > 0,
);
if (itemsWithPlaceholders.length > 0) {
// Collect all unique recipient placeholder references (e.g. "r1", "r2").
const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []);
const uniqueRecipientRefs = new Map<number, string>();
for (const p of allPlaceholders) {
const match = p.recipient.match(/^r(\d+)$/i);
if (match) {
const index = Number(match[1]);
if (!uniqueRecipientRefs.has(index)) {
uniqueRecipientRefs.set(index, `Recipient ${index}`);
}
}
}
// Fetch existing recipients (may have been created above from data.recipients or defaults).
let availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true },
});
const shouldCreatePlaceholderRecipients =
(!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0;
// If recipients were not provided, create placeholder recipients even when defaults exist.
if (shouldCreatePlaceholderRecipients) {
const existingRecipientEmails = new Set(
availableRecipients.map((recipient) => recipient.email.toLowerCase()),
);
const placeholderRecipients = Array.from(
uniqueRecipientRefs.entries(),
([recipientIndex, name]) => ({
envelopeId: envelope.id,
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
}),
).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));
if (placeholderRecipients.length > 0) {
await tx.recipient.createMany({
data: placeholderRecipients,
});
// eslint-disable-next-line require-atomic-updates
availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true },
});
}
}
for (const item of itemsWithPlaceholders) {
const envelopeItem = envelope.envelopeItems.find(
(ei) => ei.documentDataId === item.documentDataId,
);
if (!envelopeItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
item.placeholders ?? [],
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
data.recipients && data.recipients.length > 0
? data.recipients.map((r) => {
const found = availableRecipients.find((cr) => cr.email === r.email);
if (!found) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient not found for email: ${r.email}`,
});
}
return found;
})
: undefined,
availableRecipients,
),
envelopeItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,
@@ -432,8 +557,12 @@ export const createEnvelope = async ({
recipients: true,
fields: true,
folder: true,
envelopeItems: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
@@ -491,4 +620,6 @@ export const createEnvelope = async ({
return createdEnvelope;
});
return createdEnvelope;
};
@@ -1,8 +1,11 @@
import { PDF } from '@libpdf/core';
import { EnvelopeType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
@@ -11,30 +14,53 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
type CoordinatePosition = {
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type PlaceholderPosition = {
placeholder: string;
width?: number;
height?: number;
/**
* When true, creates a field at every occurrence of the placeholder in the PDF.
* When false or omitted, only the first occurrence is used.
*/
matchAll?: boolean;
};
type FieldPosition = CoordinatePosition | PlaceholderPosition;
export type CreateEnvelopeFieldInput = TFieldAndMeta & {
/**
* The ID of the item to insert the fields into.
*
* If blank, the first item will be used.
*/
envelopeItemId?: string;
recipientId: number;
} & FieldPosition;
export interface CreateEnvelopeFieldsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
fields: (TFieldAndMeta & {
/**
* The ID of the item to insert the fields into.
*
* If blank, the first item will be used.
*/
envelopeItemId?: string;
recipientId: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
})[];
fields: CreateEnvelopeFieldInput[];
requestMetadata: ApiRequestMetadata;
}
const isPlaceholderPosition = (position: FieldPosition): position is PlaceholderPosition => {
return 'placeholder' in position;
};
export const createEnvelopeFields = async ({
userId,
teamId,
@@ -55,8 +81,8 @@ export const createEnvelopeFields = async ({
recipients: true,
fields: true,
envelopeItems: {
select: {
id: true,
include: {
documentData: true,
},
},
},
@@ -82,8 +108,33 @@ export const createEnvelopeFields = async ({
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const hasPlaceholderFields = fields.some((field) => isPlaceholderPosition(field));
/*
Cache of loaded PDF documents keyed by envelope item ID.
Only loaded when at least one field uses placeholder positioning.
We keep the full PDF objects so we can both read text and draw white boxes
over resolved placeholders before saving back.
*/
const pdfCache = new Map<string, PDF>();
if (hasPlaceholderFields) {
for (const item of envelope.envelopeItems) {
const bytes = await getFileServerSide(item.documentData);
const pdfDoc = await PDF.load(new Uint8Array(bytes));
pdfCache.set(item.id, pdfDoc);
}
}
/*
Collect placeholder bounding boxes that need to be whited out, grouped by
envelope item ID. Populated during field resolution below.
*/
const placeholderWhiteouts = new Map<string, Array<{ pageIndex: number; bbox: BoundingBox }>>();
// Field validation and placeholder resolution.
const validatedFields = fields.flatMap((field) => {
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
// The item to attach the fields to MUST belong to the document.
@@ -111,10 +162,84 @@ export const createEnvelopeFields = async ({
});
}
const envelopeItemId = field.envelopeItemId || firstEnvelopeItem.id;
/*
Resolve field position(s). Placeholder fields are resolved by searching the
PDF text for the placeholder string and using its bounding box.
When matchAll is true, all occurrences produce fields.
*/
if (isPlaceholderPosition(field)) {
const pdfDoc = pdfCache.get(envelopeItemId);
if (!pdfDoc) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Could not load PDF for envelope item ${envelopeItemId}`,
});
}
const matches = pdfDoc.findText(field.placeholder);
if (matches.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Placeholder "${field.placeholder}" not found in PDF`,
});
}
const matchesToProcess = field.matchAll ? matches : [matches[0]];
const pages = pdfDoc.getPages();
return matchesToProcess.map((match) => {
const page = pages[match.pageIndex];
/*
Record this placeholder's bounding box for whiteout. The bbox is in
the original PDF coordinate system (points, bottom-left origin).
*/
if (!placeholderWhiteouts.has(envelopeItemId)) {
placeholderWhiteouts.set(envelopeItemId, []);
}
placeholderWhiteouts.get(envelopeItemId)!.push({
pageIndex: match.pageIndex,
bbox: match.bbox,
});
/*
Convert point-based coordinates (bottom-left origin) to percentage-based
coordinates (top-left origin) matching the system's field coordinate format.
*/
const topLeftY = page.height - match.bbox.y - match.bbox.height;
const widthPercent = field.width ?? (match.bbox.width / page.width) * 100;
const heightPercent = field.height ?? (match.bbox.height / page.height) * 100;
return {
type: field.type,
fieldMeta: field.fieldMeta,
recipientId: field.recipientId,
envelopeItemId,
recipientEmail: recipient.email,
page: match.pageIndex + 1,
positionX: (match.bbox.x / page.width) * 100,
positionY: (topLeftY / page.height) * 100,
width: widthPercent,
height: heightPercent,
};
});
}
return {
...field,
envelopeItemId: field.envelopeItemId || firstEnvelopeItem.id, // Fallback to first envelope item if no envelope item ID is provided.
type: field.type,
fieldMeta: field.fieldMeta,
recipientId: field.recipientId,
envelopeItemId,
recipientEmail: recipient.email,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
};
});
@@ -162,6 +287,39 @@ export const createEnvelopeFields = async ({
return newlyCreatedFields;
});
/*
Draw white rectangles over each resolved placeholder in the PDF to hide the
placeholder text, then persist the modified PDFs back to document storage.
*/
for (const [envelopeItemId, whiteouts] of placeholderWhiteouts) {
const pdfDoc = pdfCache.get(envelopeItemId);
if (!pdfDoc) {
continue;
}
whiteoutRegions(pdfDoc, whiteouts);
const modifiedPdfBytes = await pdfDoc.save();
const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
if (!envelopeItem) {
continue;
}
const newDocumentData = await putPdfFileServerSide({
name: 'document.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),
});
await prisma.envelopeItem.update({
where: { id: envelopeItemId },
data: { documentDataId: newDocumentData.id },
});
}
return {
fields: createdFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
@@ -0,0 +1,237 @@
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const MIN_HEIGHT_THRESHOLD = 0.01;
export type BoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
/**
* Draw white rectangles over specified regions in a loaded PDF document.
*
* Mutates the PDF in place. Coordinates use bottom-left origin (standard PDF coordinates).
*/
export const whiteoutRegions = (
pdfDoc: PDF,
regions: Array<{ pageIndex: number; bbox: BoundingBox }>,
): void => {
const pages = pdfDoc.getPages();
for (const { pageIndex, bbox } of regions) {
const page = pages[pageIndex];
page.drawRectangle({
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
color: rgb(1, 1, 1),
borderColor: rgb(1, 1, 1),
borderWidth: 2,
});
}
};
export type PlaceholderInfo = {
placeholder: string;
recipient: string;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export 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[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = [];
for (const page of pdfDoc.getPages()) {
const pageWidth = page.width;
const pageHeight = page.height;
const matches = page.findText(PLACEHOLDER_REGEX);
for (const match of matches) {
const placeholder = match.text;
/*
Extract the inner content from the placeholder match.
E.g. '{{SIGNATURE, r1, required=true}}' -> 'SIGNATURE, r1, required=true'
*/
const innerMatch = placeholder.match(/^\{\{([^}]+)\}\}$/);
if (!innerMatch) {
continue;
}
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType;
try {
fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
} catch {
// Skip placeholders with unrecognized field types.
continue;
}
/*
A recipient identifier (e.g. "r1", "R2") is required for auto-placement.
Placeholders without an explicit recipient like {{name}} are reserved for
future API use where callers can reference a placeholder by name with
optional dimensions instead of absolute coordinates.
*/
if (!recipientOrMeta || !/^r\d+$/i.test(recipientOrMeta)) {
continue;
}
const recipient = recipientOrMeta;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
LibPDF returns bbox in points with bottom-left origin.
Convert Y to top-left origin for consistency with the rest of the system.
*/
const topLeftY = pageHeight - match.bbox.y - match.bbox.height;
placeholders.push({
placeholder,
recipient,
fieldAndMeta,
page: page.index + 1,
x: match.bbox.x,
y: topLeftY,
width: match.bbox.width,
height: match.bbox.height,
pageWidth,
pageHeight,
});
}
}
return placeholders;
};
/**
* Draw white rectangles over placeholder text in a PDF.
*
* Accepts optional pre-extracted placeholders to avoid re-parsing the PDF.
*/
export const removePlaceholdersFromPDF = async (
pdf: Buffer,
placeholders?: PlaceholderInfo[],
): Promise<Buffer> => {
const resolved = placeholders ?? (await extractPlaceholdersFromPDF(pdf));
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const pages = pdfDoc.getPages();
/*
Convert PlaceholderInfo[] to whiteout regions.
PlaceholderInfo uses top-left origin, but whiteoutRegions expects bottom-left.
*/
const regions = resolved.map((p) => {
const page = pages[p.page - 1];
const bottomLeftY = page.height - p.y - p.height;
return {
pageIndex: p.page - 1,
bbox: { x: p.x, y: bottomLeftY, width: p.width, height: p.height },
};
});
whiteoutRegions(pdfDoc, regions);
const modifiedPdfBytes = await pdfDoc.save();
return Buffer.from(modifiedPdfBytes);
};
/**
* Extract placeholders from a PDF and remove them from the document.
*
* Returns the cleaned PDF buffer and the extracted placeholders. If no
* placeholders are found the original buffer is returned as-is.
*/
export const extractPdfPlaceholders = async (
pdf: Buffer,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
if (placeholders.length === 0) {
return { cleanedPdf: pdf, placeholders: [] };
}
const cleanedPdf = await removePlaceholdersFromPDF(pdf, placeholders);
return { cleanedPdf, placeholders };
};
/**
* Convert pre-extracted PlaceholderInfo[] to field creation inputs.
*
* Pure data transform — converts point-based coordinates to percentages and
* resolves recipient references via the provided callback. No DB calls.
*/
export const convertPlaceholdersToFieldInputs = (
placeholders: PlaceholderInfo[],
recipientResolver: (recipientPlaceholder: string, placeholder: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[] => {
return placeholders.map((p) => {
const xPercent = (p.x / p.pageWidth) * 100;
const yPercent = (p.y / p.pageHeight) * 100;
const widthPercent = (p.width / p.pageWidth) * 100;
const heightPercent = (p.height / p.pageHeight) * 100;
const finalHeightPercent =
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const recipient = recipientResolver(p.recipient, p.placeholder);
return {
...p.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: p.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
};
});
};
+152
View File
@@ -0,0 +1,152 @@
import { FieldType } from '@prisma/client';
import type { Recipient } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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;
};
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;
};
@@ -28,5 +28,7 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
pdfDoc.flattenAnnotations();
}
return Buffer.from(await pdfDoc.save());
const normalizedPdfBytes = await pdfDoc.save();
return Buffer.from(normalizedPdfBytes);
};
+1 -2
View File
@@ -1,4 +1,3 @@
import { msg } from '@lingui/core/macro';
import { FieldType } from '@prisma/client';
import { z } from 'zod';
@@ -88,7 +87,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text'),
text: z.string().optional(),
characterLimit: z.coerce
.number({ invalid_type_error: msg`Value must be a number`.id })
.number({ invalid_type_error: 'Value must be a number' })
.min(0)
.optional(),
textAlign: ZFieldTextAlignSchema.optional(),
@@ -1,8 +1,15 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import {
convertPlaceholdersToFieldInputs,
extractPdfPlaceholders,
} from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prefixedId } from '@documenso/lib/universal/id';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
@@ -84,14 +91,31 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
});
}
// For each file, stream to s3 and create the document data.
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId,
placeholders,
};
}),
);
@@ -131,6 +155,65 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItems) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems;
});
@@ -3,7 +3,9 @@ import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
@@ -69,7 +71,7 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
// For each file, stream to s3 and create the document data.
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
@@ -82,20 +84,22 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide(
{
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
{
flattenForm: type !== EnvelopeType.TEMPLATE,
},
);
const normalized = await normalizePdf(pdf, {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId,
placeholders,
};
}),
);
@@ -22,7 +22,7 @@ export const createEnvelopeFieldsMeta: TrpcRouteMeta = {
},
};
const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
const ZCreateFieldBaseSchema = ZEnvelopeFieldAndMetaSchema.and(
z.object({
recipientId: z.number().describe('The ID of the recipient to create the field for'),
envelopeItemId: z
@@ -31,14 +31,51 @@ const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
.describe(
'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.',
),
page: ZFieldPageNumberSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
}),
);
/**
* Position a field using explicit percentage-based coordinates.
*/
const ZCoordinatePositionSchema = z.object({
page: ZFieldPageNumberSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
});
/**
* Position a field using a PDF text placeholder (e.g. "{{name}}").
*
* The placeholder text is matched in the envelope item's PDF and the field is
* placed at the bounding box of that match. Width and height can optionally be
* overridden; when omitted the dimensions of the placeholder text are used.
*/
const ZPlaceholderPositionSchema = z.object({
placeholder: z
.string()
.describe(
'Text to search for in the PDF (e.g. "{{name}}"). The field will be placed at the location of this text.',
),
width: ZClampedFieldWidthSchema.optional().describe(
'Override the width of the field. When omitted, the width of the placeholder text is used.',
),
height: ZClampedFieldHeightSchema.optional().describe(
'Override the height of the field. When omitted, the height of the placeholder text is used.',
),
matchAll: z
.boolean()
.optional()
.describe(
'When true, creates a field at every occurrence of the placeholder in the PDF. When false or omitted, only the first occurrence is used.',
),
});
const ZCreateFieldSchema = ZCreateFieldBaseSchema.and(
z.union([ZCoordinatePositionSchema, ZPlaceholderPositionSchema]),
);
export const ZCreateEnvelopeFieldsRequestSchema = z.object({
envelopeId: z.string(),
data: ZCreateFieldSchema.array(),