From 5281194ad55372a4a94b4089eac12c81208680e6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 1 Oct 2026 18:13:41 +1000 Subject: [PATCH] fix: audit log content deletion on pdf change --- ...velope-replace-pdf-content-pruning.spec.ts | 65 +++++++++++++++++-- .../replace-envelope-item-pdf.ts | 27 ++++++-- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts b/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts index c7030cfa4..ff8106f99 100644 --- a/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts @@ -5,6 +5,7 @@ import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/ import { generateDatabaseId } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; @@ -28,6 +29,13 @@ const textMetaOnPage = (page: number) => ({ text: `Page ${page}`, }); +/** + * The type name is written out since the module declaring it carries lingui + * macros the test runner cannot transform. + */ +const getContentDeletedLogs = async (envelopeId: string) => + await prisma.documentAuditLog.findMany({ where: { envelopeId, type: 'CONTENT_DELETED' } }); + const replacePdf = async (envelopeId: string, buffer: Buffer, name: string) => { const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: envelopeId }, @@ -69,9 +77,17 @@ test('replacing a PDF drops contents which fall beyond the new page count', asyn const envelopeItemId = envelope.envelopeItems[0].id; + const [pageOneContentId, pageThreeContentId] = [ + generateDatabaseId('envelope_content'), + generateDatabaseId('envelope_content'), + ]; + await prisma.envelopeContent.createMany({ - data: [1, 3].map((page) => ({ - id: generateDatabaseId('envelope_content'), + data: [ + { id: pageOneContentId, page: 1 }, + { id: pageThreeContentId, page: 3 }, + ].map(({ id, page }) => ({ + id, envelopeId: envelope.id, envelopeItemId, contentMeta: ZEnvelopeContentMetaSchema.parse(textMetaOnPage(page)), @@ -82,16 +98,52 @@ test('replacing a PDF drops contents which fall beyond the new page count', asyn const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id }, - select: { contentMeta: true }, + select: { id: true, contentMeta: true }, }); // The page 1 content survives, the page 3 content is gone. expect(contents).toHaveLength(1); + expect(contents[0].id).toBe(pageOneContentId); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const contentMeta = contents[0].contentMeta as { page?: number }; + // The dropped content is logged the same way one removed in the editor is, + // attributed to the user who replaced the PDF. + const deletedLogs = await getContentDeletedLogs(envelope.id); - expect(contentMeta.page).toBe(1); + expect(deletedLogs).toHaveLength(1); + expect(deletedLogs[0].data).toEqual({ + contentId: pageThreeContentId, + contentType: EnvelopeContentType.TEXT, + envelopeItemId, + }); + expect(deletedLogs[0].userId).toBe(user.id); + expect(deletedLogs[0].email).toBe(user.email); +}); + +test('replacing a PDF on a template drops contents without logging', async () => { + const { user, team } = await seedUser(); + + const template = await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } }); + + await replacePdf(template.id, multiPagePdf, 'multi-page.pdf'); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: template.id }, + include: { envelopeItems: true }, + }); + + await prisma.envelopeContent.createMany({ + data: [1, 3].map((page) => ({ + id: generateDatabaseId('envelope_content'), + envelopeId: envelope.id, + envelopeItemId: envelope.envelopeItems[0].id, + contentMeta: ZEnvelopeContentMetaSchema.parse(textMetaOnPage(page)), + })), + }); + + await replacePdf(template.id, singlePagePdf, 'single-page.pdf'); + + expect(await prisma.envelopeContent.count({ where: { envelopeId: envelope.id } })).toBe(1); + expect(await getContentDeletedLogs(envelope.id)).toHaveLength(0); }); test('replacing a PDF keeps contents which still fit', async () => { @@ -121,4 +173,5 @@ test('replacing a PDF keeps contents which still fit', async () => { const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } }); expect(contents).toHaveLength(3); + expect(await getContentDeletedLogs(envelope.id)).toHaveLength(0); }); 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 c14896fb9..fc94309f6 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 @@ -4,7 +4,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import type { Envelope, EnvelopeContent, Field, Recipient } from '@prisma/client'; +import { type Envelope, type EnvelopeContent, EnvelopeType, type Field, type Recipient } from '@prisma/client'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields'; @@ -148,20 +148,35 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({ }, }); - const outOfBoundsContentIds = itemContents - .filter((content) => content.contentMeta.page > filePageCount) - .map((content) => content.id); + const outOfBoundsContents = itemContents.filter((content) => content.contentMeta.page > filePageCount); // Delete contents that reference pages beyond the new PDF's page count. - if (outOfBoundsContentIds.length > 0) { + if (outOfBoundsContents.length > 0) { await tx.envelopeContent.deleteMany({ where: { id: { - in: outOfBoundsContentIds, + in: outOfBoundsContents.map((content) => content.id), }, }, }); + if (envelope.type === EnvelopeType.DOCUMENT) { + await tx.documentAuditLog.createMany({ + data: outOfBoundsContents.map((content) => + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_DELETED, + envelopeId: envelope.id, + metadata: apiRequestMetadata, + data: { + contentId: content.id, + contentType: content.contentMeta.type, + envelopeItemId, + }, + }), + ), + }); + } + didContentsChange = true; }