diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index 04588a7b2..a27ec4d8e 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -11,6 +11,7 @@ import { Hono } from 'hono'; import type { HonoEnv } from '../../router'; import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers'; import { + type TUploadPdfResponse, ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestQuerySchema, @@ -51,9 +52,13 @@ export const filesRoute = new Hono() return c.json({ error: 'File too large' }, 400); } - const result = await putNormalizedPdfFileServerSide(file); + const { documentData } = await putNormalizedPdfFileServerSide(file); - return c.json(result); + // Typed so the response cannot drift from the shape `putPdfFile` reads + // on the client. + const response: TUploadPdfResponse = documentData; + + return c.json(response); } catch (error) { console.error('Upload failed:', error); return c.json({ error: 'Upload failed' }, 500); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 91d6be1df..62aa410a1 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -818,7 +818,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { formValues: body.formValues, }); - const newDocumentData = await putNormalizedPdfFileServerSide({ + const { documentData: newDocumentData } = await putNormalizedPdfFileServerSide({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), diff --git a/packages/app-tests/e2e/api/v2/envelope-copy-contents.spec.ts b/packages/app-tests/e2e/api/v2/envelope-copy-contents.spec.ts index d627f4db5..4be3e8846 100644 --- a/packages/app-tests/e2e/api/v2/envelope-copy-contents.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelope-copy-contents.spec.ts @@ -2,8 +2,12 @@ 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 { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta'; -import { nanoid } from '@documenso/lib/universal/id'; +import { + EnvelopeContentType, + type TEnvelopeContentMetaInput, + ZEnvelopeContentMetaSchema, +} from '@documenso/lib/types/envelope-content-meta'; +import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { seedUser } from '@documenso/prisma/seed/users'; @@ -33,6 +37,9 @@ test.describe.configure({ const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')); +// Three pages, against the single page of `example.pdf`. +const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')); + const textMeta = (text: string): TEnvelopeContentMetaInput => ({ type: EnvelopeContentType.TEXT, page: 1, @@ -346,6 +353,70 @@ test.describe('Copy contents to new envelopes', () => { expect(await prisma.documentAuditLog.count({ where: { envelopeId: sourceId } })).toBe(sourceAuditLogCount); }); + test('envelope/use leaves behind contents on pages a custom file does not have', async ({ request }) => { + // A template with a three page file. + const createForm = new FormData(); + + createForm.append( + 'payload', + JSON.stringify({ type: EnvelopeType.TEMPLATE, title: 'Template With Contents On Later Pages' }), + ); + + createForm.append('files', new File([multiPagePdfBuffer], 'multi-page.pdf', { type: 'application/pdf' })); + + const createRes = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: createForm, + }); + + expect(createRes.ok(), await createRes.text()).toBeTruthy(); + + const { id: templateId } = (await createRes.json()) as TCreateEnvelopeResponse; + + const template = await prisma.envelope.findUniqueOrThrow({ + where: { id: templateId }, + include: { envelopeItems: true }, + }); + + // A content on its first and third page, as the editor would have placed. + await prisma.envelopeContent.createMany({ + data: [1, 3].map((page) => ({ + id: generateDatabaseId('envelope_content'), + envelopeId: templateId, + envelopeItemId: template.envelopeItems[0].id, + contentMeta: ZEnvelopeContentMetaSchema.parse({ ...textMeta(`On page ${page}`), page }), + })), + }); + + // Use it with a single page file in place of the template's own. + const payload: TUseEnvelopePayload = { + envelopeId: templateId, + customDocumentData: [{ identifier: 0, envelopeItemId: template.envelopeItems[0].id }], + }; + + const useForm = new FormData(); + + useForm.append('payload', JSON.stringify(payload)); + useForm.append('files', new File([examplePdfBuffer], 'single-page.pdf', { type: 'application/pdf' })); + + const useRes = await request.post(`${baseUrl}/envelope/use`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: useForm, + }); + + expect(useRes.ok(), await useRes.text()).toBeTruthy(); + + const { id: documentId } = (await useRes.json()) as TUseEnvelopeResponse; + + // Only the page 1 content made it across, the template keeps both. + const copied = await getContentSummaries(documentId); + + expect(copied).toHaveLength(1); + expect(copied[0].contentMeta).toMatchObject({ page: 1, text: 'On page 1' }); + + expect(await prisma.envelopeContent.count({ where: { envelopeId: templateId } })).toBe(2); + }); + test('envelope/duplicate logs nothing for a template', async ({ request }) => { const sourceId = await createSourceEnvelope(request, token, EnvelopeType.TEMPLATE, { withSignerFields: true }); diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 3bf1cc2dd..e62e150bb 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -454,7 +454,7 @@ const injectFormValuesIntoDocument = async ( fileName = `${envelope.title}.pdf`; } - const newDocumentData = await putNormalizedPdfFileServerSide({ + const { documentData: newDocumentData } = await putNormalizedPdfFileServerSide({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), diff --git a/packages/lib/server-only/envelope-item/create-envelope-items.ts b/packages/lib/server-only/envelope-item/create-envelope-items.ts index b4e5dda38..fa92e4654 100644 --- a/packages/lib/server-only/envelope-item/create-envelope-items.ts +++ b/packages/lib/server-only/envelope-item/create-envelope-items.ts @@ -53,7 +53,7 @@ export const UNSAFE_createEnvelopeItems = async ({ buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues }); } - const normalized = await normalizePdf(buffer, { + const { pdf: normalized } = await normalizePdf(buffer, { flattenForm: envelope.type !== 'TEMPLATE', }); diff --git a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts index 427f42df2..c14896fb9 100644 --- a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts +++ b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts @@ -89,7 +89,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({ buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues }); } - const normalized = await normalizePdf(buffer, { + const { pdf: normalized } = await normalizePdf(buffer, { flattenForm: envelope.type !== 'TEMPLATE', }); diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 7d1b3ef36..9ae8e9ee4 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -261,7 +261,7 @@ export const createEnvelope = async ({ const buffer = await getFileServerSide(documentData); - const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), { + const { pdf: normalizedPdf } = await makeNormalizedPdf(Buffer.from(buffer), { flattenForm: type !== EnvelopeType.TEMPLATE, }); diff --git a/packages/lib/server-only/pdf/insert-page-overlay.ts b/packages/lib/server-only/pdf/insert-page-overlay.ts index ce098d351..49f16cf04 100644 --- a/packages/lib/server-only/pdf/insert-page-overlay.ts +++ b/packages/lib/server-only/pdf/insert-page-overlay.ts @@ -3,6 +3,7 @@ import type { PDF } from '@libpdf/core'; import { PDF as PDFDocument } from '@libpdf/core'; import { groupBy, unique } from 'remeda'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import type { ContentImageMap } from '../../universal/content-renderer/content-renderer'; import { insertFieldInPDFV2, type OverlayContent } from './insert-field-in-pdf-v2'; @@ -33,7 +34,9 @@ export const insertPageOverlays = async ({ pdfDoc, fields, contents = [], images const page = pdfDoc.getPage(pageNumber - 1); if (!page) { - throw new Error(`Page ${pageNumber} does not exist`); + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Page ${pageNumber} does not exist`, + }); } const pageWidth = page.width; diff --git a/packages/lib/server-only/pdf/normalize-pdf.ts b/packages/lib/server-only/pdf/normalize-pdf.ts index d2f5b8833..eb7ad9c6b 100644 --- a/packages/lib/server-only/pdf/normalize-pdf.ts +++ b/packages/lib/server-only/pdf/normalize-pdf.ts @@ -2,6 +2,14 @@ import { PDF } from '@libpdf/core'; import { AppError } from '../../errors/app-error'; +/** + * Normalize a PDF for storage: unlock it, flatten its layers and optionally + * its form. + * + * Returns the normalized bytes along with the page count, since the document + * is already parsed here and callers otherwise have to parse it again to + * learn how many pages it has. + */ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => { const shouldFlattenForm = options.flattenForm ?? true; @@ -34,5 +42,8 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean const normalizedPdfBytes = await pdfDoc.save(); - return Buffer.from(normalizedPdfBytes); + return { + pdf: Buffer.from(normalizedPdfBytes), + pageCount: pdfDoc.getPageCount(), + }; }; 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 16651c463..693cd8ab4 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -451,6 +451,13 @@ export const createDocumentFromTemplate = async ({ // Value = duplicated envelope item ID. const oldEnvelopeItemToNewEnvelopeItemIdMap: Record = {}; + // Key = original envelope item ID + // Value = the page count of the custom file which replaced its PDF. + // + // Only items given a custom file are counted, since the template's own + // files have the pages its contents were placed on. + const replacedEnvelopeItemPageCounts = new Map(); + // Duplicate the envelope item data. // Note: This is duplicated in createDocumentFromDirectTemplate const envelopeItemsToCreate = await Promise.all( @@ -496,7 +503,7 @@ export const createDocumentFromTemplate = async ({ // The copy keeps the source as its initial data, so the two share the // stored file rather than re-uploading it. - const newDocumentData = await putNormalizedPdfFileServerSide( + const { documentData: newDocumentData, filePageCount } = await putNormalizedPdfFileServerSide( { name: titleToUse, type: 'application/pdf', @@ -507,6 +514,10 @@ export const createDocumentFromTemplate = async ({ }, ); + if (foundCustomDocumentData) { + replacedEnvelopeItemPageCounts.set(item.id, filePageCount); + } + const newEnvelopeItemId = prefixedId('envelope_item'); oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId; @@ -583,11 +594,18 @@ export const createDocumentFromTemplate = async ({ const envelopeId = prefixedId('envelope'); + // Trim the contents if the replaced PDF has fewer pages than the content's page. + const contentsToCopy = template.contents.filter((content) => { + const replacedPageCount = replacedEnvelopeItemPageCounts.get(content.envelopeItemId); + + return replacedPageCount === undefined || content.contentMeta.page <= replacedPageCount; + }); + // The template's contents are remapped onto the new envelope items up front // so they can be inserted as soon as the envelope exists. const contentsToCreate = includeContents ? buildEnvelopeContentCopyData({ - contents: template.contents, + contents: contentsToCopy, envelopeId, envelopeItemIdMap: oldEnvelopeItemToNewEnvelopeItemIdMap, }) diff --git a/packages/lib/types/envelope-content-meta.ts b/packages/lib/types/envelope-content-meta.ts index 776e411fd..0ce12fbc4 100644 --- a/packages/lib/types/envelope-content-meta.ts +++ b/packages/lib/types/envelope-content-meta.ts @@ -54,7 +54,11 @@ export const ZContentLetterSpacingSchema = z * All content geometry is percentage based (0-100) relative to the page, unlike * fields which are stored in page units and clamped separately. */ -export const ZContentPageNumberSchema = z.number().min(1).describe('The page number the content will be on.'); +export const ZContentPageNumberSchema = z + .number() + .int() + .min(1) + .describe('The page number the content will be on. Starts from 1.'); export const ZContentPercentageSchema = z.number().min(0).max(100); diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index 991ecd658..3ef0cd011 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -66,6 +66,9 @@ type PutNormalizedPdfFileOptions = { /** * Uploads a pdf file and normalizes it. + * + * Returns the created document data and the file's page count, in the same + * shape as `putPdfFileServerSide`. */ export const putNormalizedPdfFileServerSide = async ( file: File, @@ -73,21 +76,26 @@ export const putNormalizedPdfFileServerSide = async ( ) => { const buffer = Buffer.from(await file.arrayBuffer()); - const normalized = await normalizePdf(buffer, normalizePdfOptions); + const { pdf: normalized, pageCount } = await normalizePdf(buffer, normalizePdfOptions); const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`; - const documentData = await putFileServerSide({ + const uploadedFile = await putFileServerSide({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(normalized), }); - return await createDocumentData({ - type: documentData.type, - data: documentData.data, + const documentData = await createDocumentData({ + type: uploadedFile.type, + data: uploadedFile.data, initialData, }); + + return { + documentData, + filePageCount: pageCount, + }; }; /** diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts index f2e84364e..c657ac377 100644 --- a/packages/trpc/server/document-router/create-document.ts +++ b/packages/trpc/server/document-router/create-document.ts @@ -46,7 +46,7 @@ export const createDocumentRoute = authenticatedProcedure }); } - const { id: documentDataId } = await putNormalizedPdfFileServerSide({ + const { documentData } = await putNormalizedPdfFileServerSide({ name: file.name, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdf), @@ -86,14 +86,14 @@ export const createDocumentRoute = authenticatedProcedure page: field.pageNumber, positionX: field.pageX, positionY: field.pageY, - documentDataId, + documentDataId: documentData.id, })), })), folderId, envelopeItems: [ { // If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it. - documentDataId, + documentDataId: documentData.id, }, ], }, diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index 4e9d0edfa..0af905196 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -144,7 +144,7 @@ export const createEnvelopeRouteCaller = async ({ }); } - const normalized = await normalizePdf(pdf, { + const { pdf: normalized } = await normalizePdf(pdf, { flattenForm: type !== EnvelopeType.TEMPLATE, }); diff --git a/packages/trpc/server/envelope-router/use-envelope.ts b/packages/trpc/server/envelope-router/use-envelope.ts index 913ed0ad6..a3ef541ad 100644 --- a/packages/trpc/server/envelope-router/use-envelope.ts +++ b/packages/trpc/server/envelope-router/use-envelope.ts @@ -76,13 +76,13 @@ export const useEnvelopeRoute = authenticatedProcedure const uploadedFiles = await Promise.all( filesToUpload.map(async (file) => { // We disable flattening here since `createDocumentFromTemplate` will handle it. - const { id: documentDataId } = await putNormalizedPdfFileServerSide(file, { + const { documentData } = await putNormalizedPdfFileServerSide(file, { flattenForm: false, }); return { name: file.name, - documentDataId, + documentDataId: documentData.id, }; }), ); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index c8aeafa64..22cba6954 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -237,7 +237,7 @@ export const templateRouter = router({ const pdf = await convertToPdf(file, ctx.logger); - const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide( + const { documentData: templateDocumentData } = await putNormalizedPdfFileServerSide( { name: file.name, type: 'application/pdf', @@ -263,7 +263,7 @@ export const templateRouter = router({ title, envelopeItems: [ { - documentDataId: templateDocumentDataId, + documentDataId: templateDocumentData.id, }, ], folderId,