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