From 4d29a66ba15b2b7d211024e9bd78f9e1e11126f8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 25 Nov 2025 14:18:35 +0000 Subject: [PATCH] feat: add find envelopes endpoint --- .vscode/settings.json | 3 +- .../e2e/api/v2/envelopes-api.spec.ts | 198 ++++++++++++++++ .../server-only/envelope/find-envelopes.ts | 223 ++++++++++++++++++ .../server/envelope-router/find-envelopes.ts | 56 +++++ .../envelope-router/find-envelopes.types.ts | 46 ++++ .../trpc/server/envelope-router/router.ts | 2 + 6 files changed, 527 insertions(+), 1 deletion(-) 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 5493d1ff7..a373088d2 100644 --- a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts @@ -24,6 +24,7 @@ import type { TCreateEnvelopeResponse, } from '@documenso/trpc/server/envelope-router/create-envelope.types'; import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-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'; @@ -557,4 +558,201 @@ test.describe('API V2 Envelopes', () => { userEmail: userA.email, }); }); + + test.describe('Envelope find endpoint', () => { + const createEnvelope = async ( + request: ReturnType['request'] extends Promise ? R : never, + 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?.documentMeta).toBeDefined(); + expect(envelope?.recipients).toBeDefined(); + expect(envelope?.fields).toBeDefined(); + expect(envelope?.envelopeItems).toBeDefined(); + expect(envelope?.user).toBeDefined(); + expect(envelope?.team).toBeDefined(); + }); + }); }); 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..057324c36 --- /dev/null +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -0,0 +1,223 @@ +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: [ + searchFilter, + { + OR: [ + { + teamId: team.id, + ...visibilityFilter, + }, + { + userId, + }, + ...teamEmailFilters, + ], + }, + { + deletedAt: null, + }, + ], + }; + + if (type) { + whereClause.type = type; + } + + if (templateId) { + whereClause.templateId = templateId; + } + + if (source) { + whereClause.source = source; + } + + if (status) { + whereClause.status = status; + } + + if (folderId !== undefined) { + whereClause.folderId = folderId; + } else { + whereClause.folderId = null; + } + + const [data, count] = await Promise.all([ + prisma.envelope.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + envelopeItems: { + select: { + envelopeId: true, + id: true, + title: true, + order: true, + }, + orderBy: { + order: 'asc', + }, + }, + documentMeta: true, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + recipients: { + orderBy: { + id: 'asc', + }, + }, + fields: true, + team: { + select: { + id: true, + url: true, + }, + }, + directLink: { + select: { + directTemplateRecipientId: true, + enabled: true, + id: true, + token: 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/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..81bc3252a --- /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 { ZEnvelopeSchema } 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: ZEnvelopeSchema.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 e11709eb2..b8473b2c8 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 { getEnvelopeRoute } from './get-envelope'; import { getEnvelopeItemsRoute } from './get-envelope-items'; import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token'; @@ -65,6 +66,7 @@ export const envelopeRouter = router({ set: setEnvelopeFieldsRoute, sign: signEnvelopeFieldRoute, }, + find: findEnvelopesRoute, get: getEnvelopeRoute, create: createEnvelopeRoute, use: useEnvelopeRoute,