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 { prisma } from '@documenso/prisma'; import { EnvelopeType, FieldType, 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'; import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; import { PDF, StandardFonts } from '@libpdf/core'; import type { APIRequestContext } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Team, User } from '@prisma/client'; const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement'); test.describe.configure({ mode: 'parallel' }); test.describe('Placeholder-based field creation', () => { let user: User, team: Team, token: string; test.beforeEach(async () => { ({ user, team } = await seedUser()); ({ token } = await createApiToken({ userId: user.id, teamId: team.id, tokenName: 'test', expiresIn: null, })); }); const createEnvelopeWithPdf = async ( request: APIRequestContext, pdfFilename: string, ): Promise => { const pdfPath = path.join(FIXTURES_DIR, pdfFilename); const pdfData = fs.readFileSync(pdfPath); const formData = new FormData(); formData.append( 'payload', JSON.stringify({ type: EnvelopeType.DOCUMENT, title: 'Placeholder Fields Test', } satisfies TCreateEnvelopePayload), ); formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' })); const res = await request.post(`${baseUrl}/envelope/create`, { headers: { Authorization: `Bearer ${token}` }, multipart: formData, }); expect(res.ok()).toBeTruthy(); return res.json(); }; const createEnvelopeItemsWithPdf = async (request: APIRequestContext, envelopeId: string, pdfFilename: string) => { const pdfPath = path.join(FIXTURES_DIR, pdfFilename); const pdfData = fs.readFileSync(pdfPath); const formData = new FormData(); formData.append('payload', JSON.stringify({ envelopeId })); formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' })); const res = await request.post(`${baseUrl}/envelope/item/create-many`, { headers: { Authorization: `Bearer ${token}` }, multipart: formData, }); expect(res.ok()).toBeTruthy(); return res.json(); }; const addRecipient = async (request: APIRequestContext, envelopeId: string) => { const payload: TCreateEnvelopeRecipientsRequest = { envelopeId, data: [ { email: user.email, name: user.name || '', role: RecipientRole.SIGNER, accessAuth: [], actionAuth: [], }, ], }; const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: payload, }); expect(res.ok()).toBeTruthy(); }; const addRecipients = async ( request: APIRequestContext, envelopeId: string, recipients: TCreateEnvelopeRecipientsRequest['data'], ) => { const payload: TCreateEnvelopeRecipientsRequest = { envelopeId, data: recipients, }; const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: payload, }); expect(res.ok()).toBeTruthy(); }; const getEnvelope = async (request: APIRequestContext, envelopeId: string): Promise => { const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, { headers: { Authorization: `Bearer ${token}` }, }); expect(res.ok()).toBeTruthy(); return res.json(); }; /** * Create a PDF with the same placeholder appearing multiple times at different locations. */ const createPdfWithDuplicatePlaceholders = async (): Promise => { const pdf = PDF.create(); const page = pdf.addPage({ size: 'letter' }); // Draw the same placeholder text at three different Y positions. page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 }); page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 }); page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 }); const bytes = await pdf.save(); return Buffer.from(bytes); }; const createEnvelopeWithPdfBuffer = async ( request: APIRequestContext, pdfBuffer: Buffer, filename: string, ): Promise => { const formData = new FormData(); formData.append( 'payload', JSON.stringify({ type: EnvelopeType.DOCUMENT, title: 'Placeholder Fields Test', } satisfies TCreateEnvelopePayload), ); formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' })); const res = await request.post(`${baseUrl}/envelope/create`, { headers: { Authorization: `Bearer ${token}` }, multipart: formData, }); expect(res.ok()).toBeTruthy(); return res.json(); }; test('should create a field at a placeholder location', async ({ request }) => { const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); await addRecipient(request, envelope.id); const envelopeData = await getEnvelope(request, envelope.id); const recipientId = envelopeData.recipients[0].id; const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: { envelopeId: envelope.id, data: [ { recipientId, type: FieldType.SIGNATURE, placeholder: '{{signature}}', }, ], }, }); expect(createFieldsRes.ok()).toBeTruthy(); const fields = await prisma.field.findMany({ where: { envelopeId: envelope.id }, }); expect(fields).toHaveLength(1); expect(fields[0].type).toBe(FieldType.SIGNATURE); // Verify the field has non-zero position/dimensions resolved from the placeholder. expect(fields[0].positionX.toNumber()).toBeGreaterThan(0); expect(fields[0].positionY.toNumber()).toBeGreaterThan(0); expect(fields[0].width.toNumber()).toBeGreaterThan(0); expect(fields[0].height.toNumber()).toBeGreaterThan(0); }); test('should override width and height when provided', async ({ request }) => { const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); await addRecipient(request, envelope.id); const envelopeData = await getEnvelope(request, envelope.id); const recipientId = envelopeData.recipients[0].id; const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: { envelopeId: envelope.id, data: [ { recipientId, type: FieldType.NAME, placeholder: '{{name}}', width: 30, height: 5, }, ], }, }); expect(createFieldsRes.ok()).toBeTruthy(); const fields = await prisma.field.findMany({ where: { envelopeId: envelope.id }, }); expect(fields).toHaveLength(1); expect(fields[0].width.toNumber()).toBeCloseTo(30, 1); expect(fields[0].height.toNumber()).toBeCloseTo(5, 1); }); test('should fail when placeholder text is not found in the PDF', async ({ request }) => { const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); await addRecipient(request, envelope.id); const envelopeData = await getEnvelope(request, envelope.id); const recipientId = envelopeData.recipients[0].id; const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: { envelopeId: envelope.id, data: [ { recipientId, type: FieldType.TEXT, placeholder: '{{nonexistent}}', }, ], }, }); expect(createFieldsRes.ok()).toBeFalsy(); }); test('should create fields using a mix of coordinate and placeholder positioning', async ({ request }) => { const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); await addRecipient(request, envelope.id); const envelopeData = await getEnvelope(request, envelope.id); const recipientId = envelopeData.recipients[0].id; const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: { envelopeId: envelope.id, data: [ { recipientId, type: FieldType.SIGNATURE, placeholder: '{{signature}}', }, { recipientId, type: FieldType.DATE, page: 1, positionX: 10, positionY: 20, width: 15, height: 3, }, ], }, }); expect(createFieldsRes.ok()).toBeTruthy(); const fields = await prisma.field.findMany({ where: { envelopeId: envelope.id }, orderBy: { type: 'asc' }, }); expect(fields).toHaveLength(2); const dateField = fields.find((f) => f.type === FieldType.DATE); const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE); expect(dateField).toBeDefined(); expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1); expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1); expect(signatureField).toBeDefined(); expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0); }); test('should create a field only at first occurrence by default', async ({ request }) => { const pdfBuffer = await createPdfWithDuplicatePlaceholders(); const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf'); await addRecipient(request, envelope.id); const envelopeData = await getEnvelope(request, envelope.id); const recipientId = envelopeData.recipients[0].id; const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: { envelopeId: envelope.id, data: [ { recipientId, type: FieldType.INITIALS, placeholder: '{{initials}}', }, ], }, }); expect(createFieldsRes.ok()).toBeTruthy(); const fields = await prisma.field.findMany({ where: { envelopeId: envelope.id }, }); // Should only create one field (first occurrence). expect(fields).toHaveLength(1); expect(fields[0].type).toBe(FieldType.INITIALS); }); test('should create fields at all occurrences when matchAll is true', async ({ request }) => { const pdfBuffer = await createPdfWithDuplicatePlaceholders(); const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf'); await addRecipient(request, envelope.id); const envelopeData = await getEnvelope(request, envelope.id); const recipientId = envelopeData.recipients[0].id; const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { headers: { Authorization: `Bearer ${token}` }, data: { envelopeId: envelope.id, data: [ { recipientId, type: FieldType.INITIALS, placeholder: '{{initials}}', matchAll: true, }, ], }, }); expect(createFieldsRes.ok()).toBeTruthy(); const fields = await prisma.field.findMany({ where: { envelopeId: envelope.id }, orderBy: { positionY: 'asc' }, }); // Should create three fields (one for each occurrence). expect(fields).toHaveLength(3); // All should be INITIALS type. expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true); // Verify they're at different Y positions. const yPositions = fields.map((f) => f.positionY.toNumber()); const uniqueYPositions = new Set(yPositions); expect(uniqueYPositions.size).toBe(3); }); test('should map placeholder recipients by signing order when adding items', async ({ request }) => { const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); await addRecipients(request, envelope.id, [ { email: 'second.recipient@documenso.com', name: 'Second Recipient', role: RecipientRole.SIGNER, signingOrder: 2, accessAuth: [], actionAuth: [], }, { email: 'first.recipient@documenso.com', name: 'First Recipient', role: RecipientRole.SIGNER, signingOrder: 1, accessAuth: [], actionAuth: [], }, ]); await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf'); const recipients = await prisma.recipient.findMany({ where: { envelopeId: envelope.id }, }); const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1); expect(firstRecipient).toBeDefined(); const fields = await prisma.field.findMany({ where: { envelopeId: envelope.id }, }); expect(fields.length).toBeGreaterThan(0); expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true); }); });