mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user