From f70b76d8b83aa25f34ef53ed7c3f84705b784d5c Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:34:03 +0000 Subject: [PATCH] feat: add envelope audit logs endpoint (#2232) --- .../v2/test-unauthorized-api-access.spec.ts | 59 ++++++++++++- .../find-envelope-audit-logs.ts | 83 +++++++++++++++++++ .../find-envelope-audit-logs.types.ts | 31 +++++++ .../trpc/server/envelope-router/router.ts | 4 + 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 packages/trpc/server/envelope-router/find-envelope-audit-logs.ts create mode 100644 packages/trpc/server/envelope-router/find-envelope-audit-logs.types.ts diff --git a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts index ca26e1f23..24cf79a43 100644 --- a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts @@ -3103,7 +3103,7 @@ test.describe('Document API V2', () => { expect(res.status()).toBe(404); }); - test('should allow authorized access to envelope use endpoint', async ({ page, request }) => { + test('should allow authorized access to envelope use endpoint', async ({ request }) => { const doc = await seedTemplate({ title: 'Team template 1', userId: userA.id, @@ -4313,5 +4313,62 @@ test.describe('Document API V2', () => { expect(res.status()).toBe(200); }); }); + + test.describe('Envelope audit logs endpoint', () => { + test('should block unauthorized access to envelope audit logs endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope audit logs endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + // Add a recipient which will trigger an audit log. + await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + name: 'Test', + email: 'test@example.com', + role: RecipientRole.SIGNER, + }, + ], + }, + }); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const data = await res.json(); + + expect(Array.isArray(data.data)).toBe(true); + expect(data.count).toEqual(1); + expect(data.data[0].type).toEqual('RECIPIENT_CREATED'); + expect(data.currentPage).toBeGreaterThanOrEqual(1); + expect(data.perPage).toBeGreaterThanOrEqual(1); + }); + }); }); }); diff --git a/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts b/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts new file mode 100644 index 000000000..a82d621e6 --- /dev/null +++ b/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts @@ -0,0 +1,83 @@ +import { EnvelopeType } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { parseDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindEnvelopeAuditLogsRequestSchema, + ZFindEnvelopeAuditLogsResponseSchema, + findEnvelopeAuditLogsMeta, +} from './find-envelope-audit-logs.types'; + +export const findEnvelopeAuditLogsRoute = authenticatedProcedure + .meta(findEnvelopeAuditLogsMeta) + .input(ZFindEnvelopeAuditLogsRequestSchema) + .output(ZFindEnvelopeAuditLogsResponseSchema) + .query(async ({ input, ctx }) => { + const { + envelopeId, + page = 1, + perPage = 50, + orderByColumn = 'createdAt', + orderByDirection = 'desc', + } = input; + + ctx.logger.info({ + input: { + envelopeId, + }, + }); + + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + type: null, + userId: ctx.user.id, + teamId: ctx.teamId, + }); + + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + // Only documents have audit logs. + if (envelope.type !== EnvelopeType.DOCUMENT) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Templates do not have audit logs.', + }); + } + + const [data, count] = await Promise.all([ + prisma.documentAuditLog.findMany({ + where: { envelopeId: envelope.id }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.documentAuditLog.count({ + where: { envelopeId: envelope.id }, + }), + ]); + + const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog)); + + return { + data: parsedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; + }); diff --git a/packages/trpc/server/envelope-router/find-envelope-audit-logs.types.ts b/packages/trpc/server/envelope-router/find-envelope-audit-logs.types.ts new file mode 100644 index 000000000..bf523a089 --- /dev/null +++ b/packages/trpc/server/envelope-router/find-envelope-audit-logs.types.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { ZDocumentAuditLogSchema } from '@documenso/lib/types/document-audit-logs'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const findEnvelopeAuditLogsMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/envelope/{envelopeId}/audit-log', + summary: 'Get envelope audit logs', + description: 'Find audit logs based on a search criteria', + tags: ['Envelope'], + }, +}; + +export const ZFindEnvelopeAuditLogsRequestSchema = ZFindSearchParamsSchema.omit({ + query: true, +}).extend({ + envelopeId: z.string().describe('Envelope ID'), + orderByColumn: z.enum(['createdAt']).optional(), + orderByDirection: z.enum(['asc', 'desc']).optional(), +}); + +export const ZFindEnvelopeAuditLogsResponseSchema = ZFindResultResponse.extend({ + data: ZDocumentAuditLogSchema.array(), +}); + +export type TFindEnvelopeAuditLogsRequest = z.infer; +export type TFindEnvelopeAuditLogsResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index e11709eb2..f7aad1850 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -18,6 +18,7 @@ import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-enve import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient'; import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient'; import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients'; +import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs'; import { getEnvelopeRoute } from './get-envelope'; import { getEnvelopeItemsRoute } from './get-envelope-items'; import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token'; @@ -65,6 +66,9 @@ export const envelopeRouter = router({ set: setEnvelopeFieldsRoute, sign: signEnvelopeFieldRoute, }, + auditLog: { + find: findEnvelopeAuditLogsRoute, + }, get: getEnvelopeRoute, create: createEnvelopeRoute, use: useEnvelopeRoute,