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 58fd93621..fd070f183 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 @@ -41,6 +41,8 @@ import type { TUseEnvelopeResponse, } from '@documenso/trpc/server/envelope-router/use-envelope.types'; +import { apiSignin } from '../../fixtures/authentication'; + const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); test.describe.configure({ @@ -2995,6 +2997,294 @@ test.describe('Document API V2', () => { }); }); + test.describe('Envelope get-many endpoint', () => { + test('should block unauthorized access to envelope get-many endpoint', async ({ + request, + }) => { + const doc1 = await seedBlankDocument(userA, teamA.id); + const doc2 = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + ids: { + type: 'envelopeId', + ids: [doc1.id, doc2.id], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data).toEqual([]); + }); + + test('should allow authorized access to envelope get-many endpoint', async ({ request }) => { + const doc1 = await seedBlankDocument(userA, teamA.id); + const doc2 = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + ids: { + type: 'envelopeId', + ids: [doc1.id, doc2.id], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data.length).toBe(2); + expect(data.map((d: { id: string }) => d.id).sort()).toEqual([doc1.id, doc2.id].sort()); + }); + + test('should only return authorized envelopes when mixing owned and unowned', async ({ + request, + }) => { + const docA = await seedBlankDocument(userA, teamA.id); + const docB = await seedBlankDocument(userB, teamB.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + ids: { + type: 'envelopeId', + ids: [docA.id, docB.id], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data.length).toBe(1); + expect(data[0].id).toBe(docA.id); + }); + + test('should block unauthorized access with documentId type', async ({ request }) => { + const doc1 = await seedBlankDocument(userA, teamA.id); + const doc2 = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + ids: { + type: 'documentId', + ids: [ + mapSecondaryIdToDocumentId(doc1.secondaryId), + mapSecondaryIdToDocumentId(doc2.secondaryId), + ], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data).toEqual([]); + }); + + test('should allow authorized access with documentId type', async ({ request }) => { + const doc1 = await seedBlankDocument(userA, teamA.id); + const doc2 = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + ids: { + type: 'documentId', + ids: [ + mapSecondaryIdToDocumentId(doc1.secondaryId), + mapSecondaryIdToDocumentId(doc2.secondaryId), + ], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data.length).toBe(2); + }); + + test('should block unauthorized access with templateId type', async ({ request }) => { + const template1 = await seedBlankTemplate(userA, teamA.id); + const template2 = await seedBlankTemplate(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + ids: { + type: 'templateId', + ids: [ + mapSecondaryIdToTemplateId(template1.secondaryId), + mapSecondaryIdToTemplateId(template2.secondaryId), + ], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data).toEqual([]); + }); + + test('should allow authorized access with templateId type', async ({ request }) => { + const template1 = await seedBlankTemplate(userA, teamA.id); + const template2 = await seedBlankTemplate(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + ids: { + type: 'templateId', + ids: [ + mapSecondaryIdToTemplateId(template1.secondaryId), + mapSecondaryIdToTemplateId(template2.secondaryId), + ], + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data.length).toBe(2); + }); + + test('should reject requests exceeding max ID limit', async ({ request }) => { + const ids = Array.from({ length: 21 }, () => 'envelope_fake123'); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/get-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + ids: { + type: 'envelopeId', + ids, + }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(400); + }); + }); + + test.describe('Envelope get-many tRPC endpoint (teamId manipulation)', () => { + test('should block access when user manipulates x-team-id to another team', async ({ + page, + }) => { + // Create documents for userA in teamA + const doc1 = await seedBlankDocument(userA, teamA.id); + const doc2 = await seedBlankDocument(userA, teamA.id); + + // Sign in as userB + await apiSignin({ page, email: userB.email }); + + const res = await page + .context() + .request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.getMany`, { + headers: { + 'x-team-id': String(teamA.id), + }, + data: { + json: { + ids: { + type: 'envelopeId', + ids: [doc1.id, doc2.id], + }, + }, + }, + }); + + // Make tRPC request with manipulated x-team-id pointing to teamA (which userB doesn't belong to) + expect(res.ok()).toBeFalsy(); + // Team not found + expect(res.status()).toBe(404); + }); + + test('should allow access when user uses their own team id', async ({ page }) => { + // Create documents for userA in teamA + const doc1 = await seedBlankDocument(userA, teamA.id); + const doc2 = await seedBlankDocument(userA, teamA.id); + + // Sign in as userA + await apiSignin({ page, email: userA.email }); + + const res = await page + .context() + .request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.getMany`, { + headers: { + 'Content-Type': 'application/json', + 'x-team-id': String(teamA.id), + }, + data: { + json: { + ids: { + type: 'envelopeId', + ids: [doc1.id, doc2.id], + }, + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const data = await res.json(); + + const items = data.result.data.json.data; + + expect(items.length).toBe(2); + expect(items.map((d: { id: string }) => d.id).sort()).toEqual([doc1.id, doc2.id].sort()); + }); + + test('should block access when switching team id mid-request to access other team data', async ({ + page, + }) => { + // Create a document for userA in teamA + const docA = await seedBlankDocument(userA, teamA.id); + // Create a document for userB in teamB + const docB = await seedBlankDocument(userB, teamB.id); + + // Sign in as userB + await apiSignin({ page, email: userB.email }); + + const res = await page + .context() + .request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.getMany`, { + headers: { + 'x-team-id': String(teamA.id), + }, + data: { + json: { + ids: { + type: 'envelopeId', + ids: [docA.id, docB.id], + }, + }, + }, + }); + + // UserB tries to access both documents by manipulating teamId to teamA + // Should fail - userB is not a member of teamA + expect(res.ok()).toBeFalsy(); + // Team not found + expect(res.status()).toBe(404); + }); + }); + test.describe('Envelope find endpoint', () => { test('should block unauthorized access to envelope find endpoint', async ({ request }) => { await seedBlankDocument(userA, teamA.id); diff --git a/packages/lib/server-only/envelope/get-envelopes-by-ids.ts b/packages/lib/server-only/envelope/get-envelopes-by-ids.ts new file mode 100644 index 000000000..fcb23b2c7 --- /dev/null +++ b/packages/lib/server-only/envelope/get-envelopes-by-ids.ts @@ -0,0 +1,213 @@ +import type { EnvelopeType, Prisma } from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { EnvelopeIdsOptions } from '../../utils/envelope'; +import { unsafeBuildEnvelopeIdsQuery } from '../../utils/envelope'; +import { getTeamById } from '../team/get-team'; + +export type GetEnvelopesByIdsOptions = { + /** + * The envelope IDs to fetch with their type. + */ + ids: EnvelopeIdsOptions; + + /** + * The user ID who has been authenticated. + */ + userId: number; + + /** + * The unvalidated team ID from the request. + */ + teamId: number; + + /** + * The type of envelope to get. + * + * Set to null to bypass check. + */ + type: EnvelopeType | null; +}; + +/** + * Fetches multiple envelopes by their IDs with proper access control. + * + * Only returns envelopes that the user has valid access to based on: + * 1. Document ownership (userId matches) + * 2. Team membership with appropriate visibility level + * 3. Team email ownership + * + * NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes. + */ +export const getEnvelopesByIds = async ({ + ids, + userId, + teamId, + type, +}: GetEnvelopesByIdsOptions) => { + const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({ + ids, + userId, + teamId, + type, + }); + + const envelopes = await prisma.envelope.findMany({ + where: envelopeWhereInput, + include: { + envelopeItems: { + include: { + documentData: true, + }, + orderBy: { + order: 'asc', + }, + }, + folder: true, + 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, + }, + }, + }, + }); + + return envelopes.map((envelope) => ({ + ...envelope, + user: { + id: envelope.user.id, + name: envelope.user.name || '', + email: envelope.user.email, + }, + })); +}; + +export type GetEnvelopesByIdsResponse = Awaited>; + +export type GetMultipleEnvelopeWhereInputOptions = { + /** + * The envelope IDs to fetch with their type. + */ + ids: EnvelopeIdsOptions; + + /** + * The user ID who has been authenticated. + */ + userId: number; + + /** + * The unknown teamId from the request. + */ + teamId: number; + + /** + * The type of envelope to get. + * + * Set to null to bypass check. + */ + type: EnvelopeType | null; +}; + +/** + * Generate the where input for a multiple envelope Prisma query. + * + * This will return a query that allows a user to get documents if they have valid access to them. + * + * NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes. + */ +export const getMultipleEnvelopeWhereInput = async ({ + ids, + userId, + teamId, + type, +}: GetMultipleEnvelopeWhereInputOptions) => { + // Backup validation incase something goes wrong. + if (!ids.ids || !userId || !teamId || type === undefined) { + console.error(`[CRTICAL ERROR]: MUST NEVER HAPPEN`); + + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope IDs not found', + }); + } + + // Validate that the user belongs to the team provided. + const team = await getTeamById({ teamId, userId }); + + const envelopeOrInput: Prisma.EnvelopeWhereInput[] = [ + // Allow access if they own the document. + { + userId, + }, + // Or, if they belong to the team that the document is associated with. + { + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, + teamId: team.id, + }, + ]; + + // Allow access to documents sent from the team email. + if (team.teamEmail) { + envelopeOrInput.push({ + user: { + email: team.teamEmail.email, + }, + }); + } + + // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + // NOTE: DO NOT PUT ANY CODE AFTER THIS POINT. + // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + + const envelopeWhereInput: Prisma.EnvelopeWhereInput = { + ...unsafeBuildEnvelopeIdsQuery(ids, type), + OR: envelopeOrInput, + }; + + // Final backup validation incase something goes wrong. + if ( + !envelopeWhereInput.OR || + envelopeWhereInput.OR.length < 2 || + !userId || + !teamId || + !team.id || + teamId !== team.id + ) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Query not valid', + }); + } + + // Do not modify this return directly, all adjustments need to be made prior to the above if statement. + return { + envelopeWhereInput, + team, + }; +}; diff --git a/packages/lib/utils/envelope.ts b/packages/lib/utils/envelope.ts index f686ae2c8..a0628106e 100644 --- a/packages/lib/utils/envelope.ts +++ b/packages/lib/utils/envelope.ts @@ -18,6 +18,8 @@ const ZDocumentIdSchema = z.string().regex(/^document_\d+$/); const ZTemplateIdSchema = z.string().regex(/^template_\d+$/); const ZEnvelopeIdSchema = z.string().regex(/^envelope_.{2,}$/); +const MAX_ENVELOPE_IDS_PER_REQUEST = 20; + export type EnvelopeIdOptions = | { type: 'envelopeId'; @@ -32,6 +34,20 @@ export type EnvelopeIdOptions = id: number; }; +export type EnvelopeIdsOptions = + | { + type: 'envelopeId'; + ids: string[]; + } + | { + type: 'documentId'; + ids: number[]; + } + | { + type: 'templateId'; + ids: number[]; + }; + /** * Parses an unknown document or template ID. * @@ -89,6 +105,87 @@ export const unsafeBuildEnvelopeIdQuery = ( .exhaustive(); }; +/** + * Parses multiple document or template IDs and builds a query filter. + * + * This is UNSAFE because it does not validate access, it only validates ID format and builds the query. + * + * @throws AppError if any ID is invalid or if the array exceeds the maximum limit + */ +export const unsafeBuildEnvelopeIdsQuery = ( + options: EnvelopeIdsOptions, + expectedEnvelopeType: EnvelopeType | null, +) => { + if (!options.ids || options.ids.length === 0) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'At least one ID is required', + }); + } + + if (options.ids.length > MAX_ENVELOPE_IDS_PER_REQUEST) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Cannot request more than ${MAX_ENVELOPE_IDS_PER_REQUEST} envelopes at once`, + }); + } + + return match(options) + .with({ type: 'envelopeId' }, (value) => { + const validatedIds: string[] = []; + + for (const id of value.ids) { + const parsed = ZEnvelopeIdSchema.safeParse(id); + + if (!parsed.success) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid envelope ID: ${id}`, + }); + } + + validatedIds.push(parsed.data); + } + + if (expectedEnvelopeType) { + return { + id: { in: validatedIds }, + type: expectedEnvelopeType, + }; + } + + return { + id: { in: validatedIds }, + }; + }) + .with({ type: 'documentId' }, (value) => { + if (expectedEnvelopeType && expectedEnvelopeType !== EnvelopeType.DOCUMENT) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Invalid document ID type', + }); + } + + const secondaryIds = value.ids.map((id) => mapDocumentIdToSecondaryId(id)); + + return { + type: EnvelopeType.DOCUMENT, + secondaryId: { in: secondaryIds }, + }; + }) + .with({ type: 'templateId' }, (value) => { + if (expectedEnvelopeType && expectedEnvelopeType !== EnvelopeType.TEMPLATE) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Invalid template ID type', + }); + } + + const secondaryIds = value.ids.map((id) => mapTemplateIdToSecondaryId(id)); + + return { + type: EnvelopeType.TEMPLATE, + secondaryId: { in: secondaryIds }, + }; + }) + .exhaustive(); +}; + /** * Maps a legacy document ID number to an envelope secondary ID. * diff --git a/packages/trpc/server/document-router/get-documents-by-ids.ts b/packages/trpc/server/document-router/get-documents-by-ids.ts new file mode 100644 index 000000000..56cfd0b6e --- /dev/null +++ b/packages/trpc/server/document-router/get-documents-by-ids.ts @@ -0,0 +1,65 @@ +import { EnvelopeType } from '@prisma/client'; + +import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; +import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetDocumentsByIdsRequestSchema, + ZGetDocumentsByIdsResponseSchema, + getDocumentsByIdsMeta, +} from './get-documents-by-ids.types'; + +export const getDocumentsByIdsRoute = authenticatedProcedure + .meta(getDocumentsByIdsMeta) + .input(ZGetDocumentsByIdsRequestSchema) + .output(ZGetDocumentsByIdsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { documentIds } = input; + + ctx.logger.info({ + input: { + documentIds, + }, + }); + + const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({ + ids: { + type: 'documentId', + ids: documentIds, + }, + userId: user.id, + teamId, + type: EnvelopeType.DOCUMENT, + }); + + const envelopes = await prisma.envelope.findMany({ + where: envelopeWhereInput, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + recipients: { + orderBy: { + id: 'asc', + }, + }, + team: { + select: { + id: true, + url: true, + }, + }, + }, + }); + + return { + data: envelopes.map((envelope) => mapEnvelopesToDocumentMany(envelope)), + }; + }); diff --git a/packages/trpc/server/document-router/get-documents-by-ids.types.ts b/packages/trpc/server/document-router/get-documents-by-ids.types.ts new file mode 100644 index 000000000..6f51483fb --- /dev/null +++ b/packages/trpc/server/document-router/get-documents-by-ids.types.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { ZDocumentManySchema } from '@documenso/lib/types/document'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const getDocumentsByIdsMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/document/get-many', + summary: 'Get multiple documents', + description: 'Retrieve multiple documents by their IDs', + tags: ['Document'], + }, +}; + +export const ZGetDocumentsByIdsRequestSchema = z.object({ + documentIds: z.array(z.number()).min(1), +}); + +export const ZGetDocumentsByIdsResponseSchema = z.object({ + data: z.array(ZDocumentManySchema), +}); + +export type TGetDocumentsByIdsRequest = z.infer; +export type TGetDocumentsByIdsResponse = z.infer; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 2784c2ae7..ba512caac 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -19,6 +19,7 @@ import { findDocumentsInternalRoute } from './find-documents-internal'; import { findInboxRoute } from './find-inbox'; import { getDocumentRoute } from './get-document'; import { getDocumentByTokenRoute } from './get-document-by-token'; +import { getDocumentsByIdsRoute } from './get-documents-by-ids'; import { getInboxCountRoute } from './get-inbox-count'; import { redistributeDocumentRoute } from './redistribute-document'; import { searchDocumentRoute } from './search-document'; @@ -27,6 +28,7 @@ import { updateDocumentRoute } from './update-document'; export const documentRouter = router({ get: getDocumentRoute, + getMany: getDocumentsByIdsRoute, find: findDocumentsRoute, create: createDocumentRoute, update: updateDocumentRoute, diff --git a/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts b/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts new file mode 100644 index 000000000..a855afd5a --- /dev/null +++ b/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts @@ -0,0 +1,34 @@ +import { getEnvelopesByIds } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetEnvelopesByIdsRequestSchema, + ZGetEnvelopesByIdsResponseSchema, + getEnvelopesByIdsMeta, +} from './get-envelopes-by-ids.types'; + +export const getEnvelopesByIdsRoute = authenticatedProcedure + .meta(getEnvelopesByIdsMeta) + .input(ZGetEnvelopesByIdsRequestSchema) + .output(ZGetEnvelopesByIdsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { ids } = input; + + ctx.logger.info({ + input: { + ids, + }, + }); + + const envelopes = await getEnvelopesByIds({ + ids, + userId: user.id, + teamId, + type: null, + }); + + return { + data: envelopes, + }; + }); diff --git a/packages/trpc/server/envelope-router/get-envelopes-by-ids.types.ts b/packages/trpc/server/envelope-router/get-envelopes-by-ids.types.ts new file mode 100644 index 000000000..89d938fe2 --- /dev/null +++ b/packages/trpc/server/envelope-router/get-envelopes-by-ids.types.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +import { ZEnvelopeSchema } from '@documenso/lib/types/envelope'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const getEnvelopesByIdsMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/envelope/get-many', + summary: 'Get multiple envelopes', + description: 'Retrieve multiple envelopes by their IDs', + tags: ['Envelope'], + }, +}; + +export const ZEnvelopeIdsSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('envelopeId'), + ids: z.array(z.string()).min(1).max(20), + }), + z.object({ + type: z.literal('documentId'), + ids: z.array(z.number()).min(1).max(20), + }), + z.object({ + type: z.literal('templateId'), + ids: z.array(z.number()).min(1).max(20), + }), +]); + +export const ZGetEnvelopesByIdsRequestSchema = z.object({ + ids: ZEnvelopeIdsSchema, +}); + +export const ZGetEnvelopesByIdsResponseSchema = z.object({ + data: z.array(ZEnvelopeSchema), +}); + +export type TGetEnvelopesByIdsRequest = z.infer; +export type TGetEnvelopesByIdsResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 4c22ea681..17ae34ecf 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -23,6 +23,7 @@ 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'; +import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids'; import { redistributeEnvelopeRoute } from './redistribute-envelope'; import { setEnvelopeFieldsRoute } from './set-envelope-fields'; import { setEnvelopeRecipientsRoute } from './set-envelope-recipients'; @@ -72,6 +73,7 @@ export const envelopeRouter = router({ find: findEnvelopeAuditLogsRoute, }, get: getEnvelopeRoute, + getMany: getEnvelopesByIdsRoute, create: createEnvelopeRoute, use: useEnvelopeRoute, update: updateEnvelopeRoute, diff --git a/packages/trpc/server/template-router/get-templates-by-ids.ts b/packages/trpc/server/template-router/get-templates-by-ids.ts new file mode 100644 index 000000000..95de1503f --- /dev/null +++ b/packages/trpc/server/template-router/get-templates-by-ids.ts @@ -0,0 +1,125 @@ +import { EnvelopeType } from '@prisma/client'; + +import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; +import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; +import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; +import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetTemplatesByIdsRequestSchema, + ZGetTemplatesByIdsResponseSchema, + getTemplatesByIdsMeta, +} from './get-templates-by-ids.types'; + +export const getTemplatesByIdsRoute = authenticatedProcedure + .meta(getTemplatesByIdsMeta) + .input(ZGetTemplatesByIdsRequestSchema) + .output(ZGetTemplatesByIdsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { templateIds } = input; + + ctx.logger.info({ + input: { + templateIds, + }, + }); + + const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({ + ids: { + type: 'templateId', + ids: templateIds, + }, + userId: user.id, + teamId, + type: EnvelopeType.TEMPLATE, + }); + + const envelopes = await prisma.envelope.findMany({ + where: envelopeWhereInput, + include: { + recipients: { + orderBy: { + id: 'asc', + }, + }, + envelopeItems: { + select: { + documentData: true, + }, + }, + fields: true, + team: { + select: { + id: true, + url: true, + }, + }, + documentMeta: { + select: { + signingOrder: true, + distributionMethod: true, + }, + }, + directLink: { + select: { + token: true, + enabled: true, + }, + }, + }, + }); + + const templates = envelopes.map((envelope) => { + const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId); + + const firstTemplateDocumentData = envelope.envelopeItems[0].documentData; + + return { + id: legacyTemplateId, + envelopeId: envelope.id, + type: envelope.templateType, + visibility: envelope.visibility, + externalId: envelope.externalId, + title: envelope.title, + userId: envelope.userId, + teamId: envelope.teamId, + authOptions: envelope.authOptions, + createdAt: envelope.createdAt, + updatedAt: envelope.updatedAt, + publicTitle: envelope.publicTitle, + publicDescription: envelope.publicDescription, + folderId: envelope.folderId, + useLegacyFieldInsertion: envelope.useLegacyFieldInsertion, + team: envelope.team + ? { + id: envelope.team.id, + url: envelope.team.url, + } + : null, + fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)), + recipients: envelope.recipients.map((recipient) => + mapRecipientToLegacyRecipient(recipient, envelope), + ), + templateMeta: envelope.documentMeta + ? { + signingOrder: envelope.documentMeta.signingOrder, + distributionMethod: envelope.documentMeta.distributionMethod, + } + : null, + directLink: envelope.directLink + ? { + token: envelope.directLink.token, + enabled: envelope.directLink.enabled, + } + : null, + templateDocumentDataId: firstTemplateDocumentData.id, // Backwards compatibility. + }; + }); + + return { + data: templates, + }; + }); diff --git a/packages/trpc/server/template-router/get-templates-by-ids.types.ts b/packages/trpc/server/template-router/get-templates-by-ids.types.ts new file mode 100644 index 000000000..87933d53b --- /dev/null +++ b/packages/trpc/server/template-router/get-templates-by-ids.types.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { ZTemplateManySchema } from '@documenso/lib/types/template'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const getTemplatesByIdsMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/template/get-many', + summary: 'Get multiple templates', + description: 'Retrieve multiple templates by their IDs', + tags: ['Template'], + }, +}; + +export const ZGetTemplatesByIdsRequestSchema = z.object({ + templateIds: z.array(z.number()).min(1), +}); + +export const ZGetTemplatesByIdsResponseSchema = z.object({ + data: z.array(ZTemplateManySchema), +}); + +export type TGetTemplatesByIdsRequest = z.infer; +export type TGetTemplatesByIdsResponse = z.infer; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 3791a2181..3ffa7f1bd 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -30,6 +30,7 @@ import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; +import { getTemplatesByIdsRoute } from './get-templates-by-ids'; import { ZBulkSendTemplateMutationSchema, ZCreateDocumentFromDirectTemplateRequestSchema, @@ -154,6 +155,11 @@ export const templateRouter = router({ }); }), + /** + * @public + */ + getMany: getTemplatesByIdsRoute, + /** * Wait until RR7 so we can passthrough documents. *