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
@@ -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;
};