From aa1cada79b92834a148c6bdfb8e304a76f833e16 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:51:51 +0000 Subject: [PATCH] feat: add find envelopes endpoint (#2244) --- .vscode/settings.json | 3 +- .../e2e/api/v2/envelopes-api.spec.ts | 197 ++++++++++++- .../v2/test-unauthorized-api-access.spec.ts | 277 ++++++++++++++++++ .../server-only/envelope/find-envelopes.ts | 197 +++++++++++++ packages/lib/types/envelope.ts | 39 ++- .../server/envelope-router/find-envelopes.ts | 56 ++++ .../envelope-router/find-envelopes.types.ts | 46 +++ .../trpc/server/envelope-router/router.ts | 2 + 8 files changed, 813 insertions(+), 4 deletions(-) create mode 100644 packages/lib/server-only/envelope/find-envelopes.ts create mode 100644 packages/trpc/server/envelope-router/find-envelopes.ts create mode 100644 packages/trpc/server/envelope-router/find-envelopes.types.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e6ff5d1a0..a39e263c5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,6 @@ }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "prisma.pinToPrisma6": true } diff --git a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts index bf7f75b40..beac50e56 100644 --- a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { type APIRequestContext, expect, test } from '@playwright/test'; import type { Team, User } from '@prisma/client'; import fs from 'node:fs'; import path from 'node:path'; @@ -27,6 +27,7 @@ import type { import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types'; import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types'; +import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types'; import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; @@ -562,6 +563,200 @@ test.describe('API V2 Envelopes', () => { }); }); + test.describe('Envelope find endpoint', () => { + const createEnvelope = async ( + request: APIRequestContext, + token: string, + payload: TCreateEnvelopePayload, + ) => { + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + + const pdfData = fs.readFileSync( + path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'), + ); + formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + return (await res.json()) as TCreateEnvelopeResponse; + }; + + test('should find envelopes with pagination', async ({ request }) => { + // Create 3 envelopes + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Document 1', + }); + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Document 2', + }); + await createEnvelope(request, tokenA, { + type: EnvelopeType.TEMPLATE, + title: 'Template 1', + }); + + // Find all envelopes + const res = await request.get(`${baseUrl}/envelope`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const response = (await res.json()) as TFindEnvelopesResponse; + + expect(response.data.length).toBe(3); + expect(response.count).toBe(3); + expect(response.currentPage).toBe(1); + expect(response.totalPages).toBe(1); + + // Test pagination + const paginatedRes = await request.get(`${baseUrl}/envelope?perPage=2&page=1`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(paginatedRes.ok()).toBeTruthy(); + const paginatedResponse = (await paginatedRes.json()) as TFindEnvelopesResponse; + + expect(paginatedResponse.data.length).toBe(2); + expect(paginatedResponse.count).toBe(3); + expect(paginatedResponse.totalPages).toBe(2); + }); + + test('should filter envelopes by type', async ({ request }) => { + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Document Only', + }); + await createEnvelope(request, tokenA, { + type: EnvelopeType.TEMPLATE, + title: 'Template Only', + }); + + // Filter by DOCUMENT type + const documentRes = await request.get(`${baseUrl}/envelope?type=DOCUMENT`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(documentRes.ok()).toBeTruthy(); + const documentResponse = (await documentRes.json()) as TFindEnvelopesResponse; + + expect(documentResponse.data.every((e) => e.type === EnvelopeType.DOCUMENT)).toBe(true); + + // Filter by TEMPLATE type + const templateRes = await request.get(`${baseUrl}/envelope?type=TEMPLATE`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(templateRes.ok()).toBeTruthy(); + const templateResponse = (await templateRes.json()) as TFindEnvelopesResponse; + + expect(templateResponse.data.every((e) => e.type === EnvelopeType.TEMPLATE)).toBe(true); + }); + + test('should filter envelopes by status', async ({ request }) => { + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Draft Document', + }); + + // Filter by DRAFT status (default for new envelopes) + const res = await request.get(`${baseUrl}/envelope?status=DRAFT`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + const response = (await res.json()) as TFindEnvelopesResponse; + + expect(response.data.every((e) => e.status === DocumentStatus.DRAFT)).toBe(true); + }); + + test('should search envelopes by query', async ({ request }) => { + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Unique Searchable Title', + }); + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Another Document', + }); + + const res = await request.get(`${baseUrl}/envelope?query=Unique%20Searchable`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + const response = (await res.json()) as TFindEnvelopesResponse; + + expect(response.data.length).toBe(1); + expect(response.data[0].title).toBe('Unique Searchable Title'); + }); + + test('should not return envelopes from other users', async ({ request }) => { + // Create envelope for userA + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'UserA Document', + }); + + // Create envelope for userB + await createEnvelope(request, tokenB, { + type: EnvelopeType.DOCUMENT, + title: 'UserB Document', + }); + + // userA should only see their own envelopes + const resA = await request.get(`${baseUrl}/envelope`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(resA.ok()).toBeTruthy(); + const responseA = (await resA.json()) as TFindEnvelopesResponse; + + expect(responseA.data.every((e) => e.title !== 'UserB Document')).toBe(true); + + // userB should only see their own envelopes + const resB = await request.get(`${baseUrl}/envelope`, { + headers: { Authorization: `Bearer ${tokenB}` }, + }); + + expect(resB.ok()).toBeTruthy(); + const responseB = (await resB.json()) as TFindEnvelopesResponse; + + expect(responseB.data.every((e) => e.title !== 'UserA Document')).toBe(true); + }); + + test('should return envelope with expected schema fields', async ({ request }) => { + await createEnvelope(request, tokenA, { + type: EnvelopeType.DOCUMENT, + title: 'Schema Test Document', + }); + + const res = await request.get(`${baseUrl}/envelope`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + const response = (await res.json()) as TFindEnvelopesResponse; + + const envelope = response.data.find((e) => e.title === 'Schema Test Document'); + + expect(envelope).toBeDefined(); + expect(envelope?.id).toBeDefined(); + expect(envelope?.type).toBe(EnvelopeType.DOCUMENT); + expect(envelope?.status).toBe(DocumentStatus.DRAFT); + expect(envelope?.recipients).toBeDefined(); + expect(envelope?.user).toBeDefined(); + expect(envelope?.team).toBeDefined(); + }); + }); + test.describe('Empty recipient tests', () => { test('Create template envelope with empty email recipient', async ({ request }) => { const payload = { 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 24cf79a43..58fd93621 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 @@ -12,14 +12,17 @@ import { } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { + DocumentStatus, DocumentVisibility, EnvelopeType, FieldType, + FolderType, Prisma, ReadStatus, RecipientRole, SendStatus, SigningStatus, + TeamMemberRole, } from '@documenso/prisma/client'; import { seedBlankDocument, @@ -28,9 +31,11 @@ import { seedPendingDocument, } from '@documenso/prisma/seed/documents'; import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedBlankTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; import { seedUser } from '@documenso/prisma/seed/users'; import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; +import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types'; import type { TUseEnvelopePayload, TUseEnvelopeResponse, @@ -2990,6 +2995,278 @@ test.describe('Document API V2', () => { }); }); + test.describe('Envelope find endpoint', () => { + test('should block unauthorized access to envelope find endpoint', async ({ request }) => { + await seedBlankDocument(userA, teamA.id); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope`, { + headers: { Authorization: `Bearer ${tokenB}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const data = (await res.json()) as TFindEnvelopesResponse; + expect(data.data.every((doc) => doc.userId !== userA.id)).toBe(true); + }); + + test('should allow authorized access to envelope find endpoint', async ({ request }) => { + await seedBlankDocument(userA, teamA.id); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const data = (await res.json()) as TFindEnvelopesResponse; + expect(data.data.length).toBeGreaterThan(0); + expect(data.data.some((doc) => doc.userId === userA.id)).toBe(true); + }); + + test('should respect team document visibility for ADMIN role', async ({ request }) => { + const adminMember = await seedTeamMember({ + teamId: teamA.id, + role: TeamMemberRole.ADMIN, + }); + + const { token: adminToken } = await createApiToken({ + userId: adminMember.id, + teamId: teamA.id, + tokenName: 'adminMember', + expiresIn: null, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + title: 'Admin Only Document', + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + title: 'Manager and Above Document', + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + visibility: DocumentVisibility.EVERYONE, + title: 'Everyone Document', + }, + }); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + expect(res.ok()).toBeTruthy(); + const data = (await res.json()) as TFindEnvelopesResponse; + + const titles = data.data.map((doc) => doc.title); + expect(titles).toContain('Admin Only Document'); + expect(titles).toContain('Manager and Above Document'); + expect(titles).toContain('Everyone Document'); + }); + + test('should respect team document visibility for MANAGER role', async ({ request }) => { + const managerMember = await seedTeamMember({ + teamId: teamA.id, + role: TeamMemberRole.MANAGER, + }); + + const { token: managerToken } = await createApiToken({ + userId: managerMember.id, + teamId: teamA.id, + tokenName: 'managerMember', + expiresIn: null, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + title: 'Admin Only Document', + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + title: 'Manager and Above Document', + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + visibility: DocumentVisibility.EVERYONE, + title: 'Everyone Document', + }, + }); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope`, { + headers: { Authorization: `Bearer ${managerToken}` }, + }); + + expect(res.ok()).toBeTruthy(); + const data = (await res.json()) as TFindEnvelopesResponse; + + const titles = data.data.map((doc) => doc.title); + expect(titles).not.toContain('Admin Only Document'); + expect(titles).toContain('Manager and Above Document'); + expect(titles).toContain('Everyone Document'); + }); + + test('should filter envelopes by folderId with authorization', async ({ request }) => { + const folder = await prisma.folder.create({ + data: { + userId: userA.id, + teamId: teamA.id, + name: 'Test Folder', + type: FolderType.DOCUMENT, + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + folderId: folder.id, + title: 'Document in Folder', + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + title: 'Document Not in Folder', + }, + }); + + const resWithFolder = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope?folderId=${folder.id}`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(resWithFolder.ok()).toBeTruthy(); + const dataWithFolder = (await resWithFolder.json()) as TFindEnvelopesResponse; + expect(dataWithFolder.data.every((doc) => doc.folderId === folder.id)).toBe(true); + expect(dataWithFolder.data.some((doc) => doc.title === 'Document in Folder')).toBe(true); + + const resUnauthorized = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope?folderId=${folder.id}`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + }, + ); + + expect(resUnauthorized.ok()).toBeTruthy(); + const dataUnauthorized = (await resUnauthorized.json()) as TFindEnvelopesResponse; + expect( + dataUnauthorized.data.every( + (doc) => doc.folderId !== folder.id || doc.userId !== userA.id, + ), + ).toBe(true); + }); + + test('should filter envelopes by type with authorization', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + type: EnvelopeType.DOCUMENT, + title: 'UserA Document', + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + type: EnvelopeType.TEMPLATE, + title: 'UserA Template', + }, + }); + + await seedBlankDocument(userB, teamB.id, { + createDocumentOptions: { + type: EnvelopeType.DOCUMENT, + title: 'UserB Document', + }, + }); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope?type=DOCUMENT`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + const data = (await res.json()) as TFindEnvelopesResponse; + expect(data.data.every((doc) => doc.type === EnvelopeType.DOCUMENT)).toBe(true); + expect(data.data.every((doc) => doc.userId === userA.id)).toBe(true); + expect(data.data.some((doc) => doc.title === 'UserA Document')).toBe(true); + expect(data.data.every((doc) => doc.title !== 'UserB Document')).toBe(true); + }); + + test('should filter envelopes by status with authorization', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + title: 'Draft Document', + status: DocumentStatus.DRAFT, + }, + }); + + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + title: 'Completed Document', + status: DocumentStatus.COMPLETED, + }, + }); + + await seedBlankDocument(userB, teamB.id, { + createDocumentOptions: { + title: 'UserB Draft', + status: DocumentStatus.DRAFT, + }, + }); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope?status=DRAFT`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + const data = (await res.json()) as TFindEnvelopesResponse; + expect(data.data.every((doc) => doc.status === DocumentStatus.DRAFT)).toBe(true); + expect(data.data.every((doc) => doc.userId === userA.id)).toBe(true); + expect(data.data.some((doc) => doc.title === 'Draft Document')).toBe(true); + expect(data.data.every((doc) => doc.title !== 'UserB Draft')).toBe(true); + expect(data.data.every((doc) => doc.title !== 'Completed Document')).toBe(true); + }); + + test('should search envelopes by query with authorization', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { + title: 'Unique Searchable Title UserA', + }, + }); + + await seedBlankDocument(userB, teamB.id, { + createDocumentOptions: { + title: 'Unique Searchable Title UserB', + }, + }); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope?query=Unique%20Searchable`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(res.ok()).toBeTruthy(); + const data = (await res.json()) as TFindEnvelopesResponse; + expect(data.data.every((doc) => doc.userId === userA.id)).toBe(true); + expect(data.data.some((doc) => doc.title.includes('UserA'))).toBe(true); + expect(data.data.every((doc) => !doc.title.includes('UserB'))).toBe(true); + }); + }); + test.describe('Envelope update endpoint', () => { test('should block unauthorized access to envelope update endpoint', async ({ request }) => { const doc = await seedBlankDocument(userA, teamA.id); diff --git a/packages/lib/server-only/envelope/find-envelopes.ts b/packages/lib/server-only/envelope/find-envelopes.ts new file mode 100644 index 000000000..03906d816 --- /dev/null +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -0,0 +1,197 @@ +import type { + DocumentSource, + DocumentStatus, + Envelope, + EnvelopeType, + Prisma, +} from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import type { FindResultResponse } from '../../types/search-params'; +import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +import { getTeamById } from '../team/get-team'; + +export type FindEnvelopesOptions = { + userId: number; + teamId: number; + type?: EnvelopeType; + templateId?: number; + source?: DocumentSource; + status?: DocumentStatus; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Pick; + direction: 'asc' | 'desc'; + }; + query?: string; + folderId?: string; +}; + +export const findEnvelopes = async ({ + userId, + teamId, + type, + templateId, + source, + status, + page = 1, + perPage = 10, + orderBy, + query = '', + folderId, +}: FindEnvelopesOptions) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + select: { + id: true, + email: true, + name: true, + }, + }); + + const team = await getTeamById({ + userId, + teamId, + }); + + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const searchFilter: Prisma.EnvelopeWhereInput = query + ? { + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { externalId: { contains: query, mode: 'insensitive' } }, + { recipients: { some: { name: { contains: query, mode: 'insensitive' } } } }, + { recipients: { some: { email: { contains: query, mode: 'insensitive' } } } }, + ], + } + : {}; + + const visibilityFilter: Prisma.EnvelopeWhereInput = { + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, + }; + + const teamEmailFilters: Prisma.EnvelopeWhereInput[] = []; + + if (team.teamEmail) { + teamEmailFilters.push( + { + user: { + email: team.teamEmail.email, + }, + }, + { + recipients: { + some: { + email: team.teamEmail.email, + }, + }, + }, + ); + } + + const whereClause: Prisma.EnvelopeWhereInput = { + AND: [ + { + OR: [ + { + teamId: team.id, + ...visibilityFilter, + }, + { + userId, + }, + ...teamEmailFilters, + ], + }, + { + folderId: folderId ?? null, + deletedAt: null, + }, + searchFilter, + ], + }; + + if (type) { + whereClause.type = type; + } + + if (templateId) { + whereClause.templateId = templateId; + } + + if (source) { + whereClause.source = source; + } + + if (status) { + whereClause.status = status; + } + + const [data, count] = await Promise.all([ + prisma.envelope.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + recipients: { + orderBy: { + id: 'asc', + }, + }, + team: { + select: { + id: true, + url: true, + }, + }, + }, + }), + prisma.envelope.count({ + where: whereClause, + }), + ]); + + const maskedData = data.map((envelope) => + maskRecipientTokensForDocument({ + document: envelope, + user, + }), + ); + + const mappedData = maskedData.map((envelope) => ({ + ...envelope, + recipients: envelope.Recipient, + user: { + id: envelope.user.id, + name: envelope.user.name || '', + email: envelope.user.email, + }, + })); + + return { + data: mappedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; +}; diff --git a/packages/lib/types/envelope.ts b/packages/lib/types/envelope.ts index a0b4cea62..7c83afcdf 100644 --- a/packages/lib/types/envelope.ts +++ b/packages/lib/types/envelope.ts @@ -115,5 +115,40 @@ export type TEnvelopeLite = z.infer; /** * A version of the envelope response schema when returning multiple envelopes at once from a single API endpoint. */ -// export const ZEnvelopeManySchema = X -// export type TEnvelopeMany = z.infer; +export const ZEnvelopeManySchema = EnvelopeSchema.pick({ + internalVersion: true, + type: true, + status: true, + source: true, + visibility: true, + templateType: true, + id: true, + secondaryId: true, + externalId: true, + createdAt: true, + updatedAt: true, + completedAt: true, + deletedAt: true, + title: true, + authOptions: true, + formValues: true, + publicTitle: true, + publicDescription: true, + userId: true, + teamId: true, + folderId: true, + templateId: true, +}).extend({ + user: z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + }), + recipients: ZEnvelopeRecipientLiteSchema.array(), + team: TeamSchema.pick({ + id: true, + url: true, + }).nullable(), +}); + +export type TEnvelopeMany = z.infer; diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts new file mode 100644 index 000000000..a16cf46f9 --- /dev/null +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -0,0 +1,56 @@ +import { findEnvelopes } from '@documenso/lib/server-only/envelope/find-envelopes'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindEnvelopesRequestSchema, + ZFindEnvelopesResponseSchema, + findEnvelopesMeta, +} from './find-envelopes.types'; + +export const findEnvelopesRoute = authenticatedProcedure + .meta(findEnvelopesMeta) + .input(ZFindEnvelopesRequestSchema) + .output(ZFindEnvelopesResponseSchema) + .query(async ({ input, ctx }) => { + const { user, teamId } = ctx; + + const { + query, + type, + templateId, + page, + perPage, + orderByDirection, + orderByColumn, + source, + status, + folderId, + } = input; + + ctx.logger.info({ + input: { + query, + type, + templateId, + source, + status, + folderId, + page, + perPage, + }, + }); + + return await findEnvelopes({ + userId: user.id, + teamId, + type, + templateId, + query, + source, + status, + page, + perPage, + folderId, + orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + }); + }); diff --git a/packages/trpc/server/envelope-router/find-envelopes.types.ts b/packages/trpc/server/envelope-router/find-envelopes.types.ts new file mode 100644 index 000000000..a75cea805 --- /dev/null +++ b/packages/trpc/server/envelope-router/find-envelopes.types.ts @@ -0,0 +1,46 @@ +import { DocumentSource, DocumentStatus, EnvelopeType } from '@prisma/client'; +import { z } from 'zod'; + +import { ZEnvelopeManySchema } from '@documenso/lib/types/envelope'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const findEnvelopesMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/envelope', + summary: 'Find envelopes', + description: 'Find envelopes based on search criteria', + tags: ['Envelope'], + }, +}; + +export const ZFindEnvelopesRequestSchema = ZFindSearchParamsSchema.extend({ + type: z + .nativeEnum(EnvelopeType) + .describe('Filter envelopes by type (DOCUMENT or TEMPLATE).') + .optional(), + templateId: z + .number() + .describe('Filter envelopes by the template ID used to create it.') + .optional(), + source: z + .nativeEnum(DocumentSource) + .describe('Filter envelopes by how it was created.') + .optional(), + status: z + .nativeEnum(DocumentStatus) + .describe('Filter envelopes by the current status.') + .optional(), + folderId: z.string().describe('Filter envelopes by folder ID.').optional(), + orderByColumn: z.enum(['createdAt']).optional(), + orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'), +}); + +export const ZFindEnvelopesResponseSchema = ZFindResultResponse.extend({ + data: ZEnvelopeManySchema.array(), +}); + +export type TFindEnvelopesRequest = z.infer; +export type TFindEnvelopesResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index f7aad1850..4c22ea681 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 { findEnvelopesRoute } from './find-envelopes'; import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs'; import { getEnvelopeRoute } from './get-envelope'; import { getEnvelopeItemsRoute } from './get-envelope-items'; @@ -66,6 +67,7 @@ export const envelopeRouter = router({ set: setEnvelopeFieldsRoute, sign: signEnvelopeFieldRoute, }, + find: findEnvelopesRoute, auditLog: { find: findEnvelopeAuditLogsRoute, },