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 c64ee7685..0cf3df241 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 @@ -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); + }); }); diff --git a/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts b/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts index d35977800..9c82b1ad7 100644 --- a/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts @@ -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`); diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 510086d52..52d046b75 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -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; diff --git a/packages/lib/server-only/envelope/duplicate-envelope.ts b/packages/lib/server-only/envelope/duplicate-envelope.ts index 90d044bce..a5a0fc0f1 100644 --- a/packages/lib/server-only/envelope/duplicate-envelope.ts +++ b/packages/lib/server-only/envelope/duplicate-envelope.ts @@ -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) { diff --git a/packages/trpc/server/document-router/duplicate-document.ts b/packages/trpc/server/document-router/duplicate-document.ts index adfcc24b8..dcd198b0c 100644 --- a/packages/trpc/server/document-router/duplicate-document.ts +++ b/packages/trpc/server/document-router/duplicate-document.ts @@ -28,6 +28,7 @@ export const duplicateDocumentRoute = authenticatedProcedure }, userId: user.id, teamId, + requestMetadata: ctx.metadata, }); return { diff --git a/packages/trpc/server/envelope-router/duplicate-envelope.ts b/packages/trpc/server/envelope-router/duplicate-envelope.ts index 228ff1c0c..630dcd37f 100644 --- a/packages/trpc/server/envelope-router/duplicate-envelope.ts +++ b/packages/trpc/server/envelope-router/duplicate-envelope.ts @@ -28,6 +28,7 @@ export const duplicateEnvelopeRoute = authenticatedProcedure type: 'envelopeId', id: envelopeId, }, + requestMetadata: ctx.metadata, overrides: { includeRecipients, includeFields, diff --git a/packages/trpc/server/envelope-router/save-as-template.ts b/packages/trpc/server/envelope-router/save-as-template.ts index 2e483a48d..d362ae0be 100644 --- a/packages/trpc/server/envelope-router/save-as-template.ts +++ b/packages/trpc/server/envelope-router/save-as-template.ts @@ -23,6 +23,7 @@ export const saveAsTemplateRoute = authenticatedProcedure type: 'envelopeId', id: envelopeId, }, + requestMetadata: ctx.metadata, overrides: { duplicateAsTemplate: true, includeRecipients, diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 3aa3403f7..c8aeafa64 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -455,6 +455,7 @@ export const templateRouter = router({ type: 'templateId', id: templateId, }, + requestMetadata: ctx.metadata, }); return mapEnvelopeToTemplateLite(duplicatedEnvelope.envelope);