refactor(pdf): move AcroForm import from upload to editor button

Per-product direction: AcroForm widget to Documenso field creation
should not happen automatically on upload. It must be a deliberate,
opt-in action on a draft envelope.

- Revert AcroForm extraction from create-envelope (route) and
  create-envelope-items upload paths. They no longer thread
  acroFormFields into envelope items or run the extractor.

- Stop flattening on upload (flattenForm: false) so widgets survive
  in the stored PDF until the user opts in.

- New tRPC mutation envelope.field.importFromPdf is the single entry
  point. It loads each item's stored PDF, extracts widgets, creates
  Field rows assigned to the first signable recipient (creating a
  placeholder Recipient 1 SIGNER when none exist), flattens the PDF
  in place, swaps documentDataId, and emits FIELD_CREATED audit log
  entries on DOCUMENT envelopes.

- Editor fields panel gains an "Import from PDF form" button next to
  "Detect with AI", gated to DRAFT envelopes. Success toasts the
  count and revalidates the editor.

- Rewrite acroform-import.spec.ts e2e to the new flow: upload
  preserves widgets and creates zero fields; service call creates
  fields, flattens PDF, audits, and cleans up old DocumentData.

- Invert four DOCUMENT-upload assertions in form-flattening.spec.ts
  to match the new preserve-widgets, no-auto-flatten contract.
  Template and template-to-doc flatten behavior is unchanged.
This commit is contained in:
ephraimduncan
2026-05-22 18:48:08 +00:00
parent d33714a4e5
commit 824117d47e
10 changed files with 686 additions and 399 deletions
@@ -1,7 +1,3 @@
import {
convertAcroFormFieldsToFieldInputs,
extractAcroFormFieldsFromPDF,
} from '@documenso/lib/server-only/pdf/acroform-fields';
import {
convertPlaceholdersToFieldInputs,
extractPdfPlaceholders,
@@ -14,10 +10,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { logger } from '@documenso/lib/utils/logger';
import { prisma } from '@documenso/prisma';
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
type UnsafeCreateEnvelopeItemsOptions = {
files: {
@@ -50,8 +44,7 @@ export const UNSAFE_createEnvelopeItems = async ({
}: UnsafeCreateEnvelopeItemsOptions) => {
const currentHighestOrderValue = envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
// For each file: extract AcroForm widgets, normalize, extract & clean
// placeholders, then upload.
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItemsToCreate = await Promise.all(
files.map(async ({ file, orderOverride, clientId }, index) => {
let buffer = Buffer.from(await file.arrayBuffer());
@@ -60,55 +53,10 @@ export const UNSAFE_createEnvelopeItems = async ({
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
// Run AcroForm extraction BEFORE normalizePdf — flattening destroys
// widget geometry, which we need to reuse as Documenso fields.
const acroFormExtraction = await extractAcroFormFieldsFromPDF(buffer, {
formValuesProvided: Boolean(envelope.formValues),
});
if (acroFormExtraction.skipReason) {
logger.info(
{
event: 'acroform-import.skip',
envelopeItemTitle: file.name,
reason: acroFormExtraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (acroFormExtraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of acroFormExtraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger.info(
{
event: 'acroform-import.unsupported',
envelopeItemTitle: file.name,
count: acroFormExtraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (acroFormExtraction.hasSignedSignature) {
logger.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemTitle: file.name,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const shouldFlatten = envelope.type !== 'TEMPLATE' && !acroFormExtraction.hasSignedSignature;
// Preserve interactive AcroForm widgets so they can be imported as
// Documenso fields on demand via the editor's "Import from PDF" button.
const normalized = await normalizePdf(buffer, {
flattenForm: shouldFlatten,
flattenForm: false,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -125,7 +73,6 @@ export const UNSAFE_createEnvelopeItems = async ({
clientId,
documentDataId: documentData.id,
placeholders,
acroFormFields: acroFormExtraction.fields,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
}),
@@ -213,75 +160,6 @@ export const UNSAFE_createEnvelopeItems = async ({
});
}
}
const pickFirstSignableRecipient = () => {
const signable = orderedRecipients.filter(
(r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER,
);
return signable[0] ?? null;
};
const firstSignableRecipient = pickFirstSignableRecipient();
if (firstSignableRecipient) {
for (const uploadedItem of envelopeItemsToCreate) {
if (!uploadedItem.acroFormFields || uploadedItem.acroFormFields.length === 0) {
continue;
}
const createdItem = createdItems.find((ci) => ci.documentDataId === uploadedItem.documentDataId);
if (!createdItem) {
continue;
}
const acroFormFieldsToCreate = convertAcroFormFieldsToFieldInputs(
uploadedItem.acroFormFields,
() => firstSignableRecipient,
createdItem.id,
);
if (acroFormFieldsToCreate.length === 0) {
continue;
}
const createdFields = await tx.field.createManyAndReturn({
data: acroFormFieldsToCreate.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,
})),
});
if (envelope.type === 'DOCUMENT') {
await tx.documentAuditLog.createMany({
data: createdFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: apiRequestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: firstSignableRecipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
}
}
return createdItems.map((item) => {
@@ -0,0 +1,337 @@
import { prisma } from '@documenso/prisma';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { nanoid } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import {
type AcroFormExtractionResult,
type AcroFormSkipReason,
convertAcroFormFieldsToFieldInputs,
extractAcroFormFieldsFromPDF,
} from '../pdf/acroform-fields';
import { normalizePdf } from '../pdf/normalize-pdf';
type UnsafeImportAcroFormFieldsOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'> & {
envelopeItems: (Pick<EnvelopeItem, 'id' | 'title' | 'documentDataId'> & {
documentData: DocumentData;
})[];
recipients: Recipient[];
};
apiRequestMetadata: ApiRequestMetadata;
};
type PerItemSkip = {
envelopeItemId: string;
envelopeItemTitle: string;
reason: AcroFormSkipReason;
};
export type ImportAcroFormFieldsResult = {
itemsProcessed: number;
fieldsCreated: number;
unsupportedCount: number;
signedSignatureCount: number;
skippedItems: PerItemSkip[];
fields: Field[];
};
type PreparedItem = {
envelopeItemId: string;
envelopeItemTitle: string;
oldDocumentDataId: string;
extraction: AcroFormExtractionResult;
/**
* The new flattened DocumentData record. Only set when the item had widgets
* AND no signed signature (signed-sig items keep their original PDF so the
* signature stays valid; their other widgets are still imported).
*/
newDocumentData?: DocumentData;
};
export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
envelope,
apiRequestMetadata,
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
// 1. Per-item: load PDF, extract widgets, flatten + upload new PDF when needed.
// Done outside the transaction — IO and PDF work is slow.
const prepared: PreparedItem[] = await Promise.all(
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
const buffer = await getFileServerSide(item.documentData);
const extraction = await extractAcroFormFieldsFromPDF(Buffer.from(buffer), {
formValuesProvided: Boolean(envelope.formValues),
});
if (extraction.skipReason) {
logger.info(
{
event: 'acroform-import.skip',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
reason: extraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (extraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of extraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger.info(
{
event: 'acroform-import.unsupported',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
count: extraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (extraction.hasSignedSignature) {
logger.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const base: PreparedItem = {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
oldDocumentDataId: item.documentDataId,
extraction,
};
// No fields to import → leave the PDF as-is.
if (extraction.fields.length === 0) {
return base;
}
// Signed signature → keep widgets in the PDF so the signature stays valid.
// Other widgets still flow into Documenso fields below.
if (extraction.hasSignedSignature) {
return base;
}
// Flatten the form, upload as a fresh DocumentData. The old record is
// deleted after the transaction commits.
const flattened = await normalizePdf(Buffer.from(buffer), {
flattenForm: true,
});
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: item.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(flattened),
});
return {
...base,
newDocumentData,
};
}),
);
const totalFieldsToCreate = prepared.reduce((sum, p) => sum + p.extraction.fields.length, 0);
const unsupportedCount = prepared.reduce((sum, p) => sum + p.extraction.unsupported.length, 0);
const signedSignatureCount = prepared.filter((p) => p.extraction.hasSignedSignature).length;
const skippedItems: PerItemSkip[] = [];
for (const p of prepared) {
const reason = p.extraction.skipReason;
if (!reason) {
continue;
}
skippedItems.push({
envelopeItemId: p.envelopeItemId,
envelopeItemTitle: p.envelopeItemTitle,
reason,
});
}
if (totalFieldsToCreate === 0) {
return {
itemsProcessed: 0,
fieldsCreated: 0,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: [],
};
}
// 2. Transaction: resolve recipient, swap documentData, create fields,
// write audit logs.
const { createdFields, importedItemsCount } = await prisma.$transaction(async (tx) => {
const pickFirstSignableRecipient = (recipients: Pick<Recipient, 'id' | 'email' | 'role' | 'signingOrder'>[]) => {
const signable = recipients.filter((r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER);
if (signable.length === 0) {
return null;
}
return [...signable].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;
})[0];
};
let availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
});
let firstSignableRecipient = pickFirstSignableRecipient(availableRecipients);
if (!firstSignableRecipient) {
// Mirror the placeholder branch in createEnvelope: create Recipient 1 as
// a SIGNER so the imported fields have somewhere to land. The user can
// reassign in the editor afterwards.
const placeholderEmail = 'recipient.1@documenso.com';
await tx.recipient.create({
data: {
envelopeId: envelope.id,
email: placeholderEmail,
name: 'Recipient 1',
role: RecipientRole.SIGNER,
signingOrder: 1,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
});
// eslint-disable-next-line require-atomic-updates
availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
});
firstSignableRecipient = pickFirstSignableRecipient(availableRecipients);
}
if (!firstSignableRecipient) {
// Should be unreachable — we just created one.
throw new Error('Failed to resolve a signable recipient for AcroForm import');
}
const recipient = firstSignableRecipient;
const createdFields: Field[] = [];
let importedItemsCount = 0;
for (const item of prepared) {
if (item.extraction.fields.length === 0) {
continue;
}
// Swap to the flattened PDF when we produced one.
if (item.newDocumentData) {
await tx.envelopeItem.update({
where: { id: item.envelopeItemId },
data: { documentDataId: item.newDocumentData.id },
});
}
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
item.extraction.fields,
() => recipient,
item.envelopeItemId,
);
const itemCreatedFields = await tx.field.createManyAndReturn({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: item.envelopeItemId,
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,
})),
});
createdFields.push(...itemCreatedFields);
importedItemsCount += 1;
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: itemCreatedFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: apiRequestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: recipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
return { createdFields, importedItemsCount };
});
// 3. Delete orphaned old DocumentData records. Outside the transaction so a
// delete failure does not undo the import.
await Promise.all(
prepared
.filter((p) => p.newDocumentData !== undefined)
.map((p) =>
prisma.documentData.delete({ where: { id: p.oldDocumentDataId } }).catch((err) => {
logger.error(
{
event: 'acroform-import.delete-old-document-data-failed',
envelopeItemId: p.envelopeItemId,
oldDocumentDataId: p.oldDocumentDataId,
err,
},
'Failed to delete orphaned DocumentData after AcroForm import',
);
}),
),
);
return {
itemsProcessed: importedItemsCount,
fieldsCreated: createdFields.length,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: createdFields,
};
};
@@ -1,6 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { AcroFormFieldImportInfo } from '@documenso/lib/server-only/pdf/acroform-fields';
import { convertAcroFormFieldsToFieldInputs } from '@documenso/lib/server-only/pdf/acroform-fields';
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';
@@ -10,7 +8,6 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { logger } from '@documenso/lib/utils/logger';
import { prisma } from '@documenso/prisma';
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
import {
@@ -75,7 +72,6 @@ export type CreateEnvelopeOptions = {
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[];
}[];
formValues?: TDocumentFormValues;
@@ -541,140 +537,6 @@ export const createEnvelope = async ({
}
}
// Create fields from imported AcroForm widgets (extracted at upload time).
// Runs after the placeholder branch so placeholder-created recipients are
// visible in `availableRecipientsForAcroForm`.
const itemsWithAcroFormFields = envelopeItems.filter(
(item) => item.acroFormFields && item.acroFormFields.length > 0,
);
if (itemsWithAcroFormFields.length > 0) {
let availableRecipientsForAcroForm = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
});
const pickFirstSignableRecipient = () => {
const signable = availableRecipientsForAcroForm.filter(
(r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER,
);
if (signable.length === 0) {
return null;
}
return [...signable].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;
})[0];
};
let firstSignableRecipient = pickFirstSignableRecipient();
if (!firstSignableRecipient) {
// No signable recipient yet — create a placeholder Recipient 1 SIGNER
// mirroring the placeholder branch's behavior.
const placeholderEmail = 'recipient.1@documenso.com';
const existingPlaceholder = availableRecipientsForAcroForm.find(
(r) => r.email.toLowerCase() === placeholderEmail,
);
if (!existingPlaceholder) {
await tx.recipient.create({
data: {
envelopeId: envelope.id,
email: placeholderEmail,
name: 'Recipient 1',
role: RecipientRole.SIGNER,
signingOrder: 1,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
});
}
// eslint-disable-next-line require-atomic-updates
availableRecipientsForAcroForm = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
});
firstSignableRecipient = pickFirstSignableRecipient();
}
if (!firstSignableRecipient) {
logger.warn(
{
event: 'acroform-import.no-signable-recipient',
envelopeId: envelope.id,
},
'AcroForm import skipped — no signable recipient available',
);
} else {
const acroFormRecipient = firstSignableRecipient;
for (const item of itemsWithAcroFormFields) {
const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId);
if (!envelopeItem) {
continue;
}
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
item.acroFormFields ?? [],
() => acroFormRecipient,
envelopeItem.id,
);
if (fieldsToCreate.length === 0) {
continue;
}
const createdFields = await tx.field.createManyAndReturn({
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,
})),
});
if (type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: createdFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: acroFormRecipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
}
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,