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
@@ -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: {