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