fix: audit logs

This commit is contained in:
David Nguyen
2026-10-01 13:24:58 +10:00
parent ba480ac7a1
commit 110f307d36
8 changed files with 384 additions and 22 deletions
@@ -14,7 +14,7 @@ import type {
TUseEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import { createImageFile } from '../../fixtures/contents';
@@ -56,7 +56,12 @@ const IMAGE_META: TEnvelopeContentMetaInput = {
* Create an envelope with two files through the public create route, with a
* text on each file and an image on the second.
*/
const createSourceEnvelope = async (request: APIRequestContext, token: string, type: EnvelopeType) => {
const createSourceEnvelope = async (
request: APIRequestContext,
token: string,
type: EnvelopeType,
options: { withSignerFields?: boolean } = {},
) => {
const image = await createImageFile(`copy-${nanoid()}.png`, 120, 60);
const formData = new FormData();
@@ -66,6 +71,19 @@ const createSourceEnvelope = async (request: APIRequestContext, token: string, t
JSON.stringify({
type,
title: 'Envelope With Contents',
recipients: options.withSignerFields
? [
{
email: `signer-${nanoid()}@test.documenso.com`,
name: 'Signer',
role: RecipientRole.SIGNER,
fields: [
{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 60, width: 10, height: 5 },
{ type: FieldType.NAME, page: 1, positionX: 10, positionY: 70, width: 10, height: 5 },
],
},
]
: undefined,
contents: [
{ identifier: 'first.pdf', contentMeta: textMeta('On the first file') },
{ identifier: 'second.pdf', contentMeta: textMeta('On the second file') },
@@ -196,6 +214,16 @@ const COPY_ROUTES: CopyRoute[] = [
},
];
const getCopyRoute = (name: string): CopyRoute => {
const route = COPY_ROUTES.find((copyRoute) => copyRoute.name === name);
if (!route) {
throw new Error(`Unknown copy route ${name}`);
}
return route;
};
test.describe('Copy contents to new envelopes', () => {
let token: string;
@@ -235,4 +263,80 @@ test.describe('Copy contents to new envelopes', () => {
expect(await prisma.envelopeContent.count({ where: { envelopeId: sourceId } })).toBe(3);
});
}
test('envelope/duplicate logs the copied document, fields and contents', async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, EnvelopeType.DOCUMENT, { withSignerFields: true });
const sourceAuditLogCount = await prisma.documentAuditLog.count({ where: { envelopeId: sourceId } });
const copyId = await getCopyRoute('envelope/duplicate').copy({ request, token, sourceId });
const copy = await prisma.envelope.findUniqueOrThrow({
where: { id: copyId },
include: { recipients: true, fields: true, contents: true },
});
const auditLogs = await prisma.documentAuditLog.findMany({ where: { envelopeId: copyId } });
const logsOfType = (type: string) => auditLogs.filter((log) => log.type === type);
// The copy's own creation is on record, with the copied title.
expect(logsOfType('DOCUMENT_CREATED')).toHaveLength(1);
expect(logsOfType('DOCUMENT_CREATED')[0].data).toMatchObject({ title: copy.title });
// One entry per copied field, naming the copy's own field and recipient IDs.
const [signer] = copy.recipients;
expect(copy.fields).toHaveLength(2);
expect(logsOfType('FIELD_CREATED').map((log) => log.data)).toEqual(
expect.arrayContaining(
copy.fields.map((field) => ({
fieldId: field.secondaryId,
fieldRecipientEmail: signer.email,
fieldRecipientId: signer.id,
fieldType: field.type,
})),
),
);
expect(logsOfType('FIELD_CREATED')).toHaveLength(2);
// One entry per copied content, naming the copy's own content and item IDs.
expect(copy.contents).toHaveLength(3);
expect(logsOfType('CONTENT_CREATED').map((log) => log.data)).toEqual(
expect.arrayContaining(
copy.contents.map((content) => ({
contentId: content.id,
contentType: content.contentMeta.type,
envelopeItemId: content.envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
})),
),
);
expect(logsOfType('CONTENT_CREATED')).toHaveLength(3);
// Nothing was written against the source.
expect(await prisma.documentAuditLog.count({ where: { envelopeId: sourceId } })).toBe(sourceAuditLogCount);
});
test('envelope/duplicate logs nothing for a template', async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, EnvelopeType.TEMPLATE, { withSignerFields: true });
const copyId = await getCopyRoute('envelope/duplicate').copy({ request, token, sourceId });
const copy = await prisma.envelope.findUniqueOrThrow({
where: { id: copyId },
include: { fields: true, contents: true },
});
// The fields and contents are copied, but a template has no audit trail.
expect(copy.type).toBe(EnvelopeType.TEMPLATE);
expect(copy.fields).toHaveLength(2);
expect(copy.contents).toHaveLength(3);
expect(await prisma.documentAuditLog.count({ where: { envelopeId: copyId } })).toBe(0);
});
});
@@ -154,12 +154,14 @@ const imageMeta = (positionY: number): TEnvelopeContentMetaInput => ({
test.describe('Create envelope with contents', () => {
let team: Team;
let userId: number;
let token: string;
test.beforeEach(async () => {
const seeded = await seedUser();
team = seeded.team;
userId = seeded.user.id;
({ token } = await createApiToken({
userId: seeded.user.id,
@@ -434,6 +436,114 @@ test.describe('Create envelope with contents', () => {
});
}
test('logs each created field and content on a document', async ({ request }) => {
const signerEmail = `signer-${nanoid()}@test.documenso.com`;
const res = await createEnvelopeWithContents({
request,
token,
payload: {
recipients: [
{
email: signerEmail,
name: 'Signer',
role: RecipientRole.SIGNER,
fields: [
{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 10, width: 10, height: 5 },
{ type: FieldType.NAME, page: 1, positionX: 10, positionY: 20, width: 10, height: 5 },
],
},
],
},
contents: [{ contentMeta: textMeta('Hello') }, { contentMeta: RECTANGLE_META }],
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id },
include: { recipients: true, fields: true, contents: true },
});
const auditLogs = await prisma.documentAuditLog.findMany({
where: { envelopeId: id, type: { in: ['FIELD_CREATED', 'CONTENT_CREATED'] } },
});
const fieldLogs = auditLogs.filter((log) => log.type === 'FIELD_CREATED');
const contentLogs = auditLogs.filter((log) => log.type === 'CONTENT_CREATED');
// Attributed to the user who made the request.
for (const log of auditLogs) {
expect(log.userId).toBe(userId);
}
// One entry per field, carrying the same identifiers the editor's logs do.
expect(fieldLogs).toHaveLength(2);
const [signer] = envelope.recipients;
const byFieldId = (a: { fieldId: string }, b: { fieldId: string }) => a.fieldId.localeCompare(b.fieldId);
const loggedFields = fieldLogs.map((log) => log.data as { fieldId: string }).sort(byFieldId);
const expectedFields = envelope.fields
.map((field) => ({
fieldId: field.secondaryId,
fieldRecipientEmail: signer.email,
fieldRecipientId: signer.id,
fieldType: field.type,
}))
.sort(byFieldId);
expect(loggedFields).toEqual(expectedFields);
// One entry per content, with the full meta as created.
expect(contentLogs).toHaveLength(2);
for (const content of envelope.contents) {
const log = contentLogs.find((log) => {
const data = log.data as { contentId?: string };
return data.contentId === content.id;
});
expect(log?.data).toEqual({
contentId: content.id,
contentType: content.contentMeta.type,
envelopeItemId: content.envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: null,
});
}
});
test('does not log fields or contents on a template', async ({ request }) => {
const res = await createEnvelopeWithContents({
request,
token,
payload: {
type: EnvelopeType.TEMPLATE,
recipients: [
{
email: `signer-${nanoid()}@test.documenso.com`,
name: 'Signer',
role: RecipientRole.SIGNER,
fields: [{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 10, width: 10, height: 5 }],
},
],
},
contents: [{ contentMeta: textMeta('Hello') }],
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
expect(await prisma.documentAuditLog.count({ where: { envelopeId: id } })).toBe(0);
});
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
const externalId = `e2e-contents-format-${nanoid()}`;
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
@@ -7,6 +7,7 @@ import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipient
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { generateDatabaseId, nanoid, prefixedId } from '@documenso/lib/universal/id';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
@@ -516,25 +517,29 @@ export const createEnvelope = async ({
}),
);
if (contents.length > 0) {
// Built up front so the generated IDs can be reused for the audit logs
// without reading the rows back.
const contentsToCreate = contents.map((content) => {
const envelopeItem = envelope.envelopeItems.find((item) => item.documentDataId === content.documentDataId);
if (!envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
return {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
};
});
if (contentsToCreate.length > 0) {
await tx.envelopeContent.createMany({
data: contents.map((content) => {
const envelopeItem = envelope.envelopeItems.find((item) => item.documentDataId === content.documentDataId);
if (!envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
return {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
};
}),
data: contentsToCreate,
});
}
@@ -708,6 +713,57 @@ export const createEnvelope = async ({
}),
});
}
const fieldAuditLogs: CreateDocumentAuditLogDataResponse[] = createdEnvelope.fields.map((field) => {
const fieldRecipient = createdEnvelope.recipients.find((recipient) => recipient.id === field.recipientId);
if (!fieldRecipient) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Recipient ${field.recipientId} not found for field ${field.secondaryId}`,
});
}
return createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: fieldRecipient.email,
fieldRecipientId: fieldRecipient.id,
fieldType: field.type,
},
});
});
const contentAuditLogs: CreateDocumentAuditLogDataResponse[] = contentsToCreate.map((content) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_CREATED,
envelopeId: envelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
contentId: content.id,
contentType: content.contentMeta.type,
envelopeItemId: content.envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
},
}),
);
const itemAuditLogs = [...fieldAuditLogs, ...contentAuditLogs];
if (itemAuditLogs.length > 0) {
await tx.documentAuditLog.createMany({
data: itemAuditLogs,
});
}
}
return createdEnvelope;
@@ -4,9 +4,13 @@ import pMap from 'p-map';
import { omit } from 'remeda';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { nanoid, prefixedId } from '../../universal/id';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildEnvelopeContentCopyData } from '../../utils/envelope-content';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -19,6 +23,7 @@ export interface DuplicateEnvelopeOptions {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
overrides?: {
duplicateAsTemplate?: boolean;
includeRecipients?: boolean;
@@ -27,7 +32,13 @@ export interface DuplicateEnvelopeOptions {
};
}
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
export const duplicateEnvelope = async ({
id,
userId,
teamId,
requestMetadata,
overrides,
}: DuplicateEnvelopeOptions) => {
const {
duplicateAsTemplate = false,
includeRecipients = true,
@@ -189,11 +200,37 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
}),
);
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
const isAuditLogRequired = duplicatedEnvelope.type === EnvelopeType.DOCUMENT;
if (isAuditLogRequired) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
envelopeId: duplicatedEnvelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
title: duplicatedEnvelope.title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
);
}
if (includeRecipients) {
await pMap(
const duplicatedRecipients = await pMap(
envelope.recipients,
async (recipient) =>
prisma.recipient.create({
include: {
fields: true,
},
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
@@ -224,6 +261,29 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
}),
{ concurrency: 5 },
);
if (isAuditLogRequired) {
const fieldAuditLogs = duplicatedRecipients.flatMap((recipient) =>
recipient.fields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: duplicatedEnvelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: recipient.email,
fieldRecipientId: recipient.id,
fieldType: field.type,
},
}),
),
);
auditLogs.push(...fieldAuditLogs);
}
}
if (includeContents) {
@@ -238,6 +298,34 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
data: contentsToCreate,
});
}
if (isAuditLogRequired) {
const contentsAuditLogs = contentsToCreate.map((content) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_CREATED,
envelopeId: duplicatedEnvelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
contentId: content.id,
contentType: content.contentMeta.type,
envelopeItemId: content.envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId ?? null,
},
}),
);
auditLogs.push(...contentsAuditLogs);
}
}
if (auditLogs.length > 0) {
await prisma.documentAuditLog.createMany({
data: auditLogs,
});
}
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
@@ -28,6 +28,7 @@ export const duplicateDocumentRoute = authenticatedProcedure
},
userId: user.id,
teamId,
requestMetadata: ctx.metadata,
});
return {
@@ -28,6 +28,7 @@ export const duplicateEnvelopeRoute = authenticatedProcedure
type: 'envelopeId',
id: envelopeId,
},
requestMetadata: ctx.metadata,
overrides: {
includeRecipients,
includeFields,
@@ -23,6 +23,7 @@ export const saveAsTemplateRoute = authenticatedProcedure
type: 'envelopeId',
id: envelopeId,
},
requestMetadata: ctx.metadata,
overrides: {
duplicateAsTemplate: true,
includeRecipients,
@@ -455,6 +455,7 @@ export const templateRouter = router({
type: 'templateId',
id: templateId,
},
requestMetadata: ctx.metadata,
});
return mapEnvelopeToTemplateLite(duplicatedEnvelope.envelope);