mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +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,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,
|
||||
|
||||
Reference in New Issue
Block a user