fix: audit log content deletion on pdf change

This commit is contained in:
David Nguyen
2026-10-01 18:13:41 +10:00
parent e3b6629c76
commit 5281194ad5
2 changed files with 80 additions and 12 deletions
@@ -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);
});
@@ -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;
}