From c976e747e37c292514f5e0da7b1a6b371613044e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 14 Jan 2026 12:06:28 +1100 Subject: [PATCH] fix: dont flatten forms for templates (#2386) Templates shouldn't have their form flattened until they're converted to a document. --- assets/form-fields-test.pdf | Bin 0 -> 4410 bytes packages/api/v1/implementation.ts | 54 +- .../e2e/scenarios/form-flattening.spec.ts | 848 ++++++++++++++++++ .../create-document-from-template.spec.ts | 4 +- .../lib/server-only/document/send-document.ts | 4 +- .../server-only/envelope/create-envelope.ts | 4 +- packages/lib/server-only/pdf/normalize-pdf.ts | 11 +- .../template/create-document-from-template.ts | 21 +- .../lib/universal/upload/put-file.server.ts | 7 +- .../server/envelope-router/create-envelope.ts | 17 +- .../server/envelope-router/use-envelope.ts | 7 +- .../envelope-router/use-envelope.types.ts | 3 + .../trpc/server/template-router/router.ts | 6 +- .../trpc/server/template-router/schema.ts | 3 + 14 files changed, 918 insertions(+), 71 deletions(-) create mode 100644 assets/form-fields-test.pdf create mode 100644 packages/app-tests/e2e/scenarios/form-flattening.spec.ts diff --git a/assets/form-fields-test.pdf b/assets/form-fields-test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1627237f9c6cccac062ac6f12a04e42c2b9add32 GIT binary patch literal 4410 zcmY!laBNm;4MV6`BwYek8Ba7kiGDo8U>03_y{nv$95lwSe#r~wd~85${= zTN;79>zJ39UjhmMbC7uohG3^C7(#->2&5PqDtRS9mx2@;8AAnu2E#(g5-0@opA*6! zBLhPPBU6wyzKJD8nIP*y<{Lmspg18fD_DRmgLwlIaZvwq=?9l4m4KrX6zISJ%giZB zEmF{T%K>`cB{exeC6!CxCp9mJL=8jKm3|+m|mz-F*$k*K@DP{5!^_uivZKva!>hzx$;?^6J3)|m?P7O>@ax+y6E)-AcYd6w6@sV-vN^@0EphDdV3tnJ! zjfO8R!xxlcv87*N@-@Vld?`qfCI)B;(va_v0neSkT{Q~R-l}OvMp}ux9bpmTdCTCv z|GcGBD_7A(eY>AE7Zn<|Rn7F={+2mH_1wWdfBOFBJ`R6hanEB`k;DR>6066xxwm|t znOM&JJp0|2<%01)ehBPeQ>axZD8T*Q(&gd44zKVvSFUs}={bAxL1(A-H_>}n&z@A= zS-eQO$gPTTeu=Rzw)AI4_wX?{K?xq?5CaA$XM>q@PN#O9(ski@F;h#Tk;BI|V&^W8 z8UY=z=SB?OEIXN9PwiZk-1tUhLxkRR!H!dmfo=*?Ua19^g{(!447uuZBG^0!D*5Og z`Nk-|%k_gt{()(e)iO74GML^cD(qpfg<0pX-E)p6tq$S%cPYQ$%vGv6JALY(d3FN6 zlb$p!y}xPy6zOouJI`(NmxS8O25!GtbL@ZA)~QVglLUC~7c6J)jd*gqU3FX9tE+lB z7T)t^R)2}yTW0x0$olFGgPF`#7G4MU=s4e;`(#n6p2@-VCE~Y?Ok%JF5vUZVZxESV zpo9>f_%GPkIIoq7M|^3qWW&+sJB_dKr2+L`d1ovh1m<$=9vrQ}s8WG}+YGP-gukA) zM2Yvc{kypiIS9D^{@JyXwXm#QZnr|ytd7>+4fYB`&5K-J{?wUYbkb41^Q=hnS%FnV zs=}M%{KRd!>&`zbw3A!rrdg!Y%{ljY4b%A<mXMHJdZR5Ns<(uIt;x}hO2d|r5pqqBHKRhBJpM^p!6m>px1WNc z5vb)|R9vE9VGM6>o0*~(-&aFovu_)S)P7&TqyO&dFS?Pc5}%&(dR@Q3b}G#2WYTiu zEoGi_Hvan^>*@CVb8p2Mkm!SkoFceGiszWjNPpoVf6M_lcCy)Y-24n&Pbdg1vZ`hIs4D3bH#?)^RlH zp~W$o{*C=dPb^C~!{OyBU!19NtHo09{l--@?T?9Vyp`z2sQuXB-rCpKq}ua8Z2xgX zMQM7d*{ti9b9&ENKAmNBXVZcCf4?az9S*fyzxsc9@x1!LsqW=7R3=&<-Eu;Hv+13M z<{B~9_CMTOj{Qp8#_ZqdQ}6m@(T8B4$*Py<`t(iWiY~9c+Iuwj%*Agdq0X^NPfIvW zlos2~u@n*s;}`rn$Nt}$*GY!#Vuwq1F1D?$JlFW;^rltHMVo)d{r;Fc(e{bNj&tIs zpC*4>%#<5@R`lqtf=*MRd1t~aBin5sbELe8iK|HGw|kiK?0?7JvNdeZJgiZ1yhb}d zUWiVpo6*$cQU548zM=hnVp4xz)YRB*dM7@3e=dDzbRo}7>2XnDs_V}O-$K5fF5Sp0 zW68DWKzsVdty8vhbBh^mvurLDF8&s{{Br!ZU)h`2*B9K@ zi)Uv1@rwUwkpD&GOtp3On#4(q0!?n_zs zZ`S&cd-=H&#Ae((<>97#poF2&{dwP&!}9n1d<>@Ks9P;}_kDH$P~qpYqV%Ac&#!KJ z7H>bQbwuNc_q07?v$lF@M(zH6O*Y>8bwt&+{nKPr+7G{zO1m%g_N&)Fwo88}2St%!cRGEdw3W${mGW-Cr1Y$Z2xGcY)_DpkSA0@y#z&o5Dc zbhY(8^V0Gaz@1+leNAX@ASg8ro)Ij8W`u*gy+%M|fE`}Xyp&W(H{8eq$Vcvzs-8K> z*r1ymNc7@K7I|UDi}$4OYW>4`S#gT&55|*@oGn;9U0jk_ UR8mm{bg+q`i7A(=s;j>n0Mko!SpWb4 literal 0 HcmV?d00001 diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index c7ec90f2b..a92578034 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -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: { diff --git a/packages/app-tests/e2e/scenarios/form-flattening.spec.ts b/packages/app-tests/e2e/scenarios/form-flattening.spec.ts new file mode 100644 index 000000000..1e7e2db51 --- /dev/null +++ b/packages/app-tests/e2e/scenarios/form-flattening.spec.ts @@ -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 { + 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 { + 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 { + 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); + }); + }); +}); diff --git a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts index e9ca3d6b8..6929b004f 100644 --- a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts +++ b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts @@ -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, ); diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index f4729f331..4e7a49a12 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -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), diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 27d3c1e4a..cb60bdca5 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -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; diff --git a/packages/lib/server-only/pdf/normalize-pdf.ts b/packages/lib/server-only/pdf/normalize-pdf.ts index 071134411..57993642e 100644 --- a/packages/lib/server-only/pdf/normalize-pdf.ts +++ b/packages/lib/server-only/pdf/normalize-pdf.ts @@ -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()); }; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index bea4ea690..230b0a0e3 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -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, }, }); diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index 47c2be747..3ecce8a3b 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -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`; diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index 607847797..60f98962d 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -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, diff --git a/packages/trpc/server/envelope-router/use-envelope.ts b/packages/trpc/server/envelope-router/use-envelope.ts index ebe6af280..f4385086e 100644 --- a/packages/trpc/server/envelope-router/use-envelope.ts +++ b/packages/trpc/server/envelope-router/use-envelope.ts @@ -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 diff --git a/packages/trpc/server/envelope-router/use-envelope.types.ts b/packages/trpc/server/envelope-router/use-envelope.types.ts index 20933dd9e..5121ba31a 100644 --- a/packages/trpc/server/envelope-router/use-envelope.types.ts +++ b/packages/trpc/server/envelope-router/use-envelope.types.ts @@ -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({ diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 3ffa7f1bd..31c39dead 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -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) { diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 94917d41f..5b02d5cc4 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -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;