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
@@ -18,17 +18,19 @@ import {
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -85,6 +87,10 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const { toast } = useToast();
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
trpc.envelope.field.importFromPdf.useMutation();
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
@@ -152,6 +158,39 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
const onImportFromPdfClick = async () => {
try {
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
if (result.fieldsCreated === 0) {
toast({
title: _(msg`No form fields found`),
description: _(msg`This PDF does not contain any importable form fields.`),
duration: 5000,
});
return;
}
await revalidate();
toast({
title: _(msg`Fields imported`),
description: _(
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
),
duration: 5000,
});
} catch {
toast({
title: _(msg`Could not import fields`),
description: _(msg`Something went wrong while importing fields from the PDF.`),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
@@ -274,6 +313,23 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => void onImportFromPdfClick()}
disabled={envelope.status !== DocumentStatus.DRAFT || isImportingFieldsFromPdf}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only import fields in draft envelopes`)
: undefined
}
>
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
</Button>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
@@ -1,8 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -10,6 +11,7 @@ import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@@ -17,12 +19,52 @@ const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf'));
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const form = pdfDoc.getForm();
return Boolean(form && form.fieldCount > 0);
};
const uploadAcroFormEnvelope = async ({
request,
token,
payload,
}: {
request: import('@playwright/test').APIRequestContext;
token: string;
payload: TCreateEnvelopePayload;
}) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const loadEnvelopeForImport = async (envelopeId: string) =>
prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
test.describe.configure({
mode: 'parallel',
});
test.describe('AcroForm Import', () => {
test('imports AcroForm widgets as Documenso fields assigned to the provided signer', async ({ request }) => {
test('upload does not create fields and preserves widgets in the stored PDF', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -31,32 +73,81 @@ test.describe('AcroForm Import', () => {
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
},
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
// No fields are created at upload time.
expect(envelope.fields).toHaveLength(0);
// The stored PDF still carries the original AcroForm widgets — they
// survive the upload pipeline and are available to the import button.
const pdfBuffer = await getFileServerSide(envelope.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import creates fields assigned to the signer, flattens the PDF, and emits audit logs', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
},
});
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
const result = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: {
requestMetadata: {},
source: 'apiV1',
auth: 'api',
},
});
expect(result.fieldsCreated).toBeGreaterThan(0);
expect(result.itemsProcessed).toBe(1);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
@@ -65,28 +156,35 @@ test.describe('AcroForm Import', () => {
},
});
expect(envelope.recipients).toHaveLength(1);
expect(envelope.recipients[0].email).toBe('signer@example.com');
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
// Every imported field is assigned to the single signer.
expect(envelope.fields.length).toBeGreaterThanOrEqual(8);
expect(envelope.fields.every((f) => f.recipientId === envelope.recipients[0].id)).toBe(true);
// Every imported field has source: 'acroform' on its fieldMeta.
for (const field of envelope.fields) {
for (const field of after.fields) {
const meta = field.fieldMeta as { source?: string } | null;
expect(meta?.source).toBe('acroform');
}
// FIELD_CREATED audit log entries match the number of imported fields.
const auditEntries = await prisma.documentAuditLog.findMany({
where: { envelopeId: envelope.id, type: 'FIELD_CREATED' },
where: { envelopeId: after.id, type: 'FIELD_CREATED' },
});
expect(auditEntries.length).toBe(envelope.fields.length);
expect(auditEntries.length).toBe(after.fields.length);
// The envelope item now points at a new (flat) DocumentData record.
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const flattenedPdf = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(flattenedPdf)).toBe(false);
// The old DocumentData record has been cleaned up.
const oldRecord = await prisma.documentData.findUnique({ where: { id: oldDocumentDataId } });
expect(oldRecord).toBeNull();
});
test('creates a placeholder Recipient 1 SIGNER when no recipients are provided', async ({ request }) => {
test('import creates a placeholder Recipient 1 SIGNER when no recipients exist', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -95,36 +193,37 @@ test.describe('AcroForm Import', () => {
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document without recipients',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
recipients: true,
fields: true,
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document without recipients',
},
});
expect(envelope.recipients).toHaveLength(1);
expect(envelope.recipients[0].email).toBe('recipient.1@documenso.com');
expect(envelope.recipients[0].role).toBe(RecipientRole.SIGNER);
expect(envelope.fields.length).toBeGreaterThanOrEqual(8);
expect(envelope.fields.every((f) => f.recipientId === envelope.recipients[0].id)).toBe(true);
const envelope = await loadEnvelopeForImport(response.id);
expect(envelope.recipients).toHaveLength(0);
await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: {
requestMetadata: {},
source: 'apiV1',
auth: 'api',
},
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { recipients: true, fields: true },
});
expect(after.recipients).toHaveLength(1);
expect(after.recipients[0].email).toBe('recipient.1@documenso.com');
expect(after.recipients[0].role).toBe(RecipientRole.SIGNER);
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
});
});
@@ -93,7 +93,7 @@ test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
test('should preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -136,16 +136,19 @@ test.describe('Form Flattening', () => {
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
// DOCUMENT uploads no longer auto-flatten — the editor's "Import from
// PDF form" button is the new flatten trigger. Inserted values remain
// visible in the (still-interactive) widgets.
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
});
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -157,7 +160,8 @@ test.describe('Form Flattening', () => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
// No formValues - form fields stay interactive until the user clicks
// "Import from PDF form" in the editor.
};
const formData = new FormData();
@@ -184,13 +188,11 @@ test.describe('Form Flattening', () => {
},
});
// Get the PDF and verify form fields are flattened
// DOCUMENT uploads no longer auto-flatten.
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
});
@@ -747,11 +749,11 @@ test.describe('Form Flattening', () => {
},
});
// Form should still be flattened for DOCUMENT type
// DOCUMENT uploads no longer auto-flatten; widgets remain interactive.
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
@@ -798,11 +800,13 @@ test.describe('Form Flattening', () => {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
// DOCUMENT uploads no longer auto-flatten; widgets remain interactive
// and the inserted value is visible inside the still-editable field.
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
});
});
});
@@ -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,
@@ -2,12 +2,10 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { extractAcroFormFieldsFromPDF } from '@documenso/lib/server-only/pdf/acroform-fields';
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
@@ -107,10 +105,7 @@ export const createEnvelopeRouteCaller = async ({
});
}
// For each file: convert to PDF if needed, extract AcroForm widgets,
// normalize (which flattens the form unless we detected a signed signature
// and unless this is a template upload), extract & clean placeholders,
// then upload.
// For each file: convert to PDF if needed, normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = await convertToPdf(file, logger);
@@ -123,55 +118,10 @@ export const createEnvelopeRouteCaller = async ({
});
}
// Run AcroForm extraction BEFORE normalizePdf — flattening destroys
// widget geometry, which we need to reuse as Documenso fields.
const acroFormExtraction = await extractAcroFormFieldsFromPDF(pdf, {
formValuesProvided: Boolean(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 = type !== EnvelopeType.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(pdf, {
flattenForm: shouldFlatten,
flattenForm: false,
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
@@ -187,7 +137,6 @@ export const createEnvelopeRouteCaller = async ({
title: file.name,
documentDataId: documentData.id,
placeholders,
acroFormFields: acroFormExtraction.fields,
};
}),
);
@@ -0,0 +1,76 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZImportAcroFormFieldsRequestSchema,
ZImportAcroFormFieldsResponseSchema,
} from './import-acroform-fields.types';
/**
* Internal-only — driven by the "Import from PDF" button in the envelope editor.
*
* Extracts AcroForm widgets from each envelope item's stored PDF, creates
* Documenso `Field` rows for the supported widgets, flattens the PDF in place
* (so widgets do not visually duplicate the imported fields), and emits a
* `FIELD_CREATED` audit entry per field on DOCUMENT envelopes.
*/
export const importAcroFormFieldsRoute = authenticatedProcedure
.input(ZImportAcroFormFieldsRequestSchema)
.output(ZImportAcroFormFieldsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId } = input;
ctx.logger.info({ input: { envelopeId } });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
include: { documentData: true },
orderBy: { order: 'asc' },
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for version 2 envelopes',
});
}
if (envelope.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only allowed while the envelope is in draft',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope has no items to import from',
});
}
return await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: metadata,
});
});
@@ -0,0 +1,24 @@
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import { z } from 'zod';
export const ZImportAcroFormFieldsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZImportAcroFormFieldsResponseSchema = z.object({
itemsProcessed: z.number().int().min(0),
fieldsCreated: z.number().int().min(0),
unsupportedCount: z.number().int().min(0),
signedSignatureCount: z.number().int().min(0),
skippedItems: z.array(
z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
reason: z.enum(['encrypted', 'xfa-hybrid', 'no-form', 'error']),
}),
),
fields: z.array(ZEnvelopeFieldSchema),
});
export type TImportAcroFormFieldsRequest = z.infer<typeof ZImportAcroFormFieldsRequestSchema>;
export type TImportAcroFormFieldsResponse = z.infer<typeof ZImportAcroFormFieldsResponseSchema>;
@@ -28,6 +28,7 @@ import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { importAcroFormFieldsRoute } from './import-acroform-fields';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
@@ -75,6 +76,7 @@ export const envelopeRouter = router({
delete: deleteEnvelopeFieldRoute,
set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute,
importFromPdf: importAcroFormFieldsRoute,
},
find: findEnvelopesRoute,
auditLog: {