mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 10:25:00 +10:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user