fix: dont flatten forms for templates (#2386)

Templates shouldn't have their form flattened until they're
converted to a document.
This commit is contained in:
Lucas Smith 2026-01-14 12:06:28 +11:00 committed by GitHub
parent 34f512bd55
commit c976e747e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 918 additions and 71 deletions

BIN
assets/form-fields-test.pdf Normal file

Binary file not shown.

View file

@ -41,7 +41,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
@ -822,7 +822,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putPdfFileServerSide({
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -911,61 +911,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
title: body.title,
...body.meta,
},
formValues: body.formValues,
requestMetadata: metadata,
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (envelope.envelopeItems.length !== 1) {
throw new Error('API V1 does not support envelopes');
}
const firstEnvelopeDocumentData = await prisma.envelopeItem.findFirstOrThrow({
where: {
envelopeId: envelope.id,
},
include: {
documentData: true,
},
});
if (body.formValues) {
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title}.pdf`;
const pdf = await getFileServerSide(firstEnvelopeDocumentData.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
formValues: body.formValues,
envelopeItems: {
update: {
where: {
id: firstEnvelopeDocumentData.id,
},
data: {
documentDataId: newDocumentData.id,
},
},
},
},
});
}
if (body.authOptions) {
await prisma.envelope.update({
where: {

View file

@ -0,0 +1,848 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Form field names in the test PDF
const FORM_FIELDS = {
TEXT_FIELD: 'test_text_field',
COMPANY_NAME: 'company_name',
CHECKBOX: 'accept_terms',
DROPDOWN: 'country',
} as const;
// Test values to insert into form fields
const TEST_FORM_VALUES = {
[FORM_FIELDS.TEXT_FIELD]: 'Hello World',
[FORM_FIELDS.COMPANY_NAME]: 'Documenso Inc.',
[FORM_FIELDS.CHECKBOX]: true,
[FORM_FIELDS.DROPDOWN]: 'Germany',
};
/**
* Helper to check if a PDF has interactive form fields.
* Returns true if the PDF has form fields, false if they've been flattened.
*/
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const form = pdfDoc.getForm();
const fields = form.getFields();
return fields.length > 0;
}
/**
* Helper to get form field names from a PDF.
*/
async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const form = pdfDoc.getForm();
const fields = form.getFields();
return fields.map((field) => field.getName());
}
/**
* Helper to get the value of a text field in a PDF.
*/
async function getPdfTextFieldValue(
pdfBuffer: Uint8Array,
fieldName: string,
): Promise<string | undefined> {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const form = pdfDoc.getForm();
try {
const textField = form.getTextField(fieldName);
return textField.getText() ?? '';
} catch {
return undefined;
}
}
test.describe.configure({
mode: 'parallel',
});
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,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Form Values',
formValues: TEST_FORM_VALUES,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Verify the envelope was created with the correct formValues
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
});
test.describe('Template Creation (TEMPLATE type)', () => {
test('should NOT flatten form fields when creating a TEMPLATE envelope', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Form Fields',
// Note: formValues can be set but form should NOT be flattened for templates
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
expect(envelope.type).toBe(EnvelopeType.TEMPLATE);
// Get the PDF and verify form fields are NOT flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(true);
// Verify the specific form fields still exist
const fieldNames = await getPdfFormFieldNames(pdfBuffer);
expect(fieldNames).toContain(FORM_FIELDS.TEXT_FIELD);
expect(fieldNames).toContain(FORM_FIELDS.COMPANY_NAME);
expect(fieldNames).toContain(FORM_FIELDS.CHECKBOX);
expect(fieldNames).toContain(FORM_FIELDS.DROPDOWN);
});
test('should preserve form fields in template even when formValues are provided', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Form Values',
formValues: TEST_FORM_VALUES,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
// formValues should be stored in the database
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.TEMPLATE);
// But the PDF should still have interactive form fields
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
});
});
test.describe('Document from Template', () => {
test('should flatten form fields when creating document from template with formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First, create a template via API
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template for Document Creation',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
// Verify template has form fields
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
const templatePdfBuffer = await getFileServerSide(template.envelopeItems[0].documentData);
expect(await pdfHasFormFields(templatePdfBuffer)).toBe(true);
// Now create a document from the template with formValues
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
formValues: TEST_FORM_VALUES,
},
});
expect(useTemplateRes.ok()).toBeTruthy();
expect(useTemplateRes.status()).toBe(200);
const documentResponse = await useTemplateRes.json();
// Get the created document
const document = await prisma.envelope.findFirstOrThrow({
where: {
id: documentResponse.envelopeId,
},
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(document.type).toBe(EnvelopeType.DOCUMENT);
// Verify form fields are flattened in the created document
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
const hasFormFields = await pdfHasFormFields(documentPdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should flatten form fields when creating document from template without formValues', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template without Form Values',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Create document from template WITHOUT formValues
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
// No formValues provided
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(document.type).toBe(EnvelopeType.DOCUMENT);
// Form fields should still be flattened even without formValues
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
const hasFormFields = await pdfHasFormFields(documentPdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should use template formValues when creating document without override', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template with formValues
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Default Form Values',
formValues: {
[FORM_FIELDS.TEXT_FIELD]: 'Default Value',
[FORM_FIELDS.COMPANY_NAME]: 'Default Company',
},
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
expect(templateRes.ok()).toBeTruthy();
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Verify template stored the formValues
expect(template.formValues).toEqual({
[FORM_FIELDS.TEXT_FIELD]: 'Default Value',
[FORM_FIELDS.COMPANY_NAME]: 'Default Company',
});
// Create document from template without providing new formValues
// The template's formValues should be used
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
// No formValues - should inherit from template
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Form fields should be flattened
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
expect(await pdfHasFormFields(documentPdfBuffer)).toBe(false);
});
});
test.describe('Form Values Verification', () => {
test('should correctly insert form values into PDF before flattening', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Create a template first (form fields preserved)
const templatePayload: TCreateEnvelopePayload = {
type: EnvelopeType.TEMPLATE,
title: 'Template for Value Verification',
recipients: [
{
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
},
],
};
const templateFormData = new FormData();
templateFormData.append('payload', JSON.stringify(templatePayload));
templateFormData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const templateRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: templateFormData,
});
const templateResponse = (await templateRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateResponse.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
// Verify template PDF still has form fields
const templatePdfBuffer = await getFileServerSide(template.envelopeItems[0].documentData);
expect(await pdfHasFormFields(templatePdfBuffer)).toBe(true);
// Verify we can read a text field value (should be empty initially)
const initialValue = await getPdfTextFieldValue(templatePdfBuffer, FORM_FIELDS.TEXT_FIELD);
expect(initialValue).toBe('');
// Now create a document with form values
const testValues = {
[FORM_FIELDS.TEXT_FIELD]: 'Inserted Text Value',
[FORM_FIELDS.COMPANY_NAME]: 'Test Company Name',
};
const useTemplateRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [
{
id: template.recipients[0].id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
formValues: testValues,
},
});
expect(useTemplateRes.ok()).toBeTruthy();
const documentResponse = await useTemplateRes.json();
const document = await prisma.envelope.findFirstOrThrow({
where: { id: documentResponse.envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// The form should be flattened, so we can't read form fields
const documentPdfBuffer = await getFileServerSide(document.envelopeItems[0].documentData);
expect(await pdfHasFormFields(documentPdfBuffer)).toBe(false);
// The values should have been inserted before flattening
// We can't verify the actual text content easily without visual inspection,
// but we can verify the form fields are gone (flattened)
const fieldNames = await getPdfFormFieldNames(documentPdfBuffer);
expect(fieldNames.length).toBe(0);
});
});
test.describe('Edge Cases', () => {
test('should handle PDF without form fields gracefully', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Use a PDF without form fields
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with No Form Fields',
formValues: {
nonexistent_field: 'Some Value',
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
// Should succeed even with formValues for non-existent fields
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
expect(envelope.formValues).toEqual({ nonexistent_field: 'Some Value' });
});
test('should handle empty formValues object', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Empty Form Values',
formValues: {},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Form should still be flattened for DOCUMENT type
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Partial Form Values',
formValues: {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
// Other fields not set
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([formFieldsPdf], 'form-fields-test.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
},
});
// Should store the partial formValues
expect(envelope.formValues).toEqual({
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
});
});

View file

@ -505,7 +505,9 @@ test('[TEMPLATE]: should create a document from a template using template docume
});
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
expect(firstDocumentData.data).toEqual(templateWithData.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.data,
);
expect(firstDocumentData.initialData).toEqual(
templateWithData.envelopeItems[0].documentData.initialData,
);

View file

@ -33,7 +33,7 @@ import {
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
@ -334,7 +334,7 @@ const injectFormValuesIntoDocument = async (
fileName = `${envelope.title}.pdf`;
}
const newDocumentData = await putPdfFileServerSide({
const newDocumentData = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),

View file

@ -185,7 +185,9 @@ export const createEnvelope = async ({
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
const titleToUse = item.title || title;

View file

@ -4,7 +4,9 @@ import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => {
const shouldFlattenForm = options.flattenForm ?? true;
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
@ -20,8 +22,11 @@ export const normalizePdf = async (pdf: Buffer) => {
}
removeOptionalContentGroups(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
if (shouldFlattenForm) {
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
}
return Buffer.from(await pdfDoc.save());
};

View file

@ -23,6 +23,7 @@ import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type {
TCheckboxFieldMeta,
TDropdownFieldMeta,
@ -43,7 +44,7 @@ import {
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -56,6 +57,7 @@ import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -118,6 +120,8 @@ export type CreateDocumentFromTemplateOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
formValues?: TDocumentFormValues;
requestMetadata: ApiRequestMetadata;
};
@ -304,6 +308,7 @@ export const createDocumentFromTemplate = async ({
folderId,
prefillFields,
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
@ -456,11 +461,19 @@ export const createDocumentFromTemplate = async ({
});
}
const buffer = await getFileServerSide(documentDataToDuplicate);
let buffer = await getFileServerSide(documentDataToDuplicate);
const titleToUse = item.title || finalEnvelopeTitle;
const duplicatedFile = await putPdfFileServerSide({
if (formValues) {
// eslint-disable-next-line require-atomic-updates
buffer = await insertFormValuesInPdf({
pdf: Buffer.from(buffer),
formValues,
});
}
const duplicatedFile = await putNormalizedPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
@ -470,7 +483,7 @@ export const createDocumentFromTemplate = async ({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
initialData: documentDataToDuplicate.data,
},
});

View file

@ -47,10 +47,13 @@ export const putPdfFileServerSide = async (file: File) => {
/**
* Uploads a pdf file and normalizes it.
*/
export const putNormalizedPdfFileServerSide = async (file: File) => {
export const putNormalizedPdfFileServerSide = async (
file: File,
options: { flattenForm?: boolean } = {},
) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer);
const normalized = await normalizePdf(buffer, options);
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;

View file

@ -1,3 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
@ -80,11 +82,16 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
});
const { id: documentDataId } = await putNormalizedPdfFileServerSide(
{
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
{
flattenForm: type !== EnvelopeType.TEMPLATE,
},
);
return {
title: file.name,

View file

@ -34,6 +34,7 @@ export const useEnvelopeRoute = authenticatedProcedure
prefillFields,
override,
attachments,
formValues,
} = payload;
ctx.logger.info({
@ -79,7 +80,10 @@ export const useEnvelopeRoute = authenticatedProcedure
// Process uploaded files and create document data for them
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
// We disable flattening here since `createDocumentFromTemplate` will handle it.
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file, {
flattenForm: false,
});
return {
name: file.name,
@ -146,6 +150,7 @@ export const useEnvelopeRoute = authenticatedProcedure
prefillFields,
override,
attachments,
formValues,
});
// Distribute document if requested

View file

@ -2,6 +2,7 @@ import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
@ -108,6 +109,8 @@ export const ZUseEnvelopePayloadSchema = z.object({
}),
)
.optional(),
formValues: ZDocumentFormValuesSchema.optional(),
});
export const ZUseEnvelopeRequestSchema = zodFormData({

View file

@ -197,7 +197,9 @@ export const templateRouter = router({
attachments,
} = payload;
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file);
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file, {
flattenForm: false,
});
ctx.logger.info({
input: {
@ -468,6 +470,7 @@ export const templateRouter = router({
externalId,
override,
attachments,
formValues,
} = input;
ctx.logger.info({
@ -507,6 +510,7 @@ export const templateRouter = router({
externalId,
override,
attachments,
formValues,
});
if (distributeDocument) {

View file

@ -8,6 +8,7 @@ import {
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
@ -172,6 +173,8 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
}),
)
.optional(),
formValues: ZDocumentFormValuesSchema.optional(),
});
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;