diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index b0db12bab..673da91a2 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -9,6 +9,7 @@ import { z } from 'zod'; import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -172,7 +173,11 @@ export default function DocumentsPage() { {value !== ExtendedDocumentStatus.ALL && ( - {stats[value]} + + {stats[value] >= STATS_COUNT_CAP + ? `${STATS_COUNT_CAP.toLocaleString()}+` + : stats[value]} + )} diff --git a/packages/app-tests/e2e/api/v2/find-documents.spec.ts b/packages/app-tests/e2e/api/v2/find-documents.spec.ts new file mode 100644 index 000000000..448de9b83 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/find-documents.spec.ts @@ -0,0 +1,1595 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { + seedBlankDocument, + seedCompletedDocument, + seedDocuments, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types'; + +import { apiSignin } from '../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; + +test.describe.configure({ + mode: 'parallel', +}); + +// Helper to make authenticated GET requests to the find documents endpoint. +const findDocuments = async ( + request: import('@playwright/test').APIRequestContext, + token: string, + params: Record = {}, +) => { + const searchParams = new URLSearchParams(params); + const url = `${baseUrl}/document${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + const res = await request.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return { + res, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + json: res.ok() ? ((await res.json()) as TFindDocumentsResponse) : null, + }; +}; + +test.describe('Find Documents API - Personal Context', () => { + let userA: User, teamA: Team, tokenA: string; + let userB: User, teamB: Team, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'tokenB', + expiresIn: null, + })); + }); + + test('should return empty results when no documents exist', async ({ request }) => { + const { res, json } = await findDocuments(request, tokenA); + + expect(res.ok()).toBeTruthy(); + expect(json).toBeDefined(); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + expect(json!.currentPage).toBe(1); + expect(json!.totalPages).toBe(0); + }); + + test('should return only documents owned by the user and not the other user', async ({ + request, + }) => { + // The v2 API token scopes to a team. A personal team token only returns + // docs belonging to that team — cross-team received docs are NOT included. + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Draft 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Draft 2' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'UserA Pending' }, + }); + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Draft 1' }, + }); + await seedPendingDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'UserB Pending' }, + }); + await seedCompletedDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'UserB Completed' }, + }); + + const { json: jsonA } = await findDocuments(request, tokenA); + const { json: jsonB } = await findDocuments(request, tokenB); + + const titlesA = jsonA!.data.map((d) => d.title); + // UserA sees only their own team's docs + expect(titlesA).toContain('UserA Draft 1'); + expect(titlesA).toContain('UserA Draft 2'); + expect(titlesA).toContain('UserA Pending'); + // Cross-team received docs are NOT visible via personal team token + expect(titlesA).not.toContain('UserB Pending'); + expect(titlesA).not.toContain('UserB Completed'); + expect(titlesA).not.toContain('UserB Draft 1'); + expect(jsonA!.count).toBe(3); + + const titlesB = jsonB!.data.map((d) => d.title); + expect(titlesB).toContain('UserB Draft 1'); + expect(titlesB).toContain('UserB Pending'); + expect(titlesB).toContain('UserB Completed'); + expect(titlesB).not.toContain('UserA Draft 1'); + expect(titlesB).not.toContain('UserA Draft 2'); + expect(titlesB).not.toContain('UserA Pending'); + expect(jsonB!.count).toBe(3); + }); + + test('should only return documents belonging to the personal team, not cross-team received docs', async ({ + request, + }) => { + // The v2 API scopes to a team. Cross-team docs where the user is a recipient + // are NOT returned — they belong to the sender's team, not the recipient's. + await seedPendingDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'Pending for A from B Team' }, + }); + await seedCompletedDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'Completed for A from B Team' }, + }); + + // UserA's own docs (should be returned) + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Own Draft' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'UserA Own Pending' }, + }); + + const { json } = await findDocuments(request, tokenA); + const titles = json!.data.map((d) => d.title); + + expect(titles).toContain('UserA Own Draft'); + expect(titles).toContain('UserA Own Pending'); + // Cross-team received docs NOT visible + expect(titles).not.toContain('Pending for A from B Team'); + expect(titles).not.toContain('Completed for A from B Team'); + expect(json!.count).toBe(2); + }); + + test('should NOT leak documents between unrelated users', async ({ request }) => { + const { user: userC, team: teamC } = await seedUser(); + + // Each user has their own docs + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Own Doc' }, + }); + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Own Doc' }, + }); + + await seedDraftDocument(userC, teamC.id, [], { + createDocumentOptions: { title: 'UserC Private Draft' }, + }); + await seedPendingDocument(userC, teamC.id, [userC], { + createDocumentOptions: { title: 'UserC Pending' }, + }); + await seedCompletedDocument(userC, teamC.id, [userC], { + createDocumentOptions: { title: 'UserC Completed' }, + }); + + const { json: jsonA } = await findDocuments(request, tokenA); + const { json: jsonB } = await findDocuments(request, tokenB); + + // UserA should see only their own doc + expect(jsonA!.data).toHaveLength(1); + expect(jsonA!.data[0].title).toBe('UserA Own Doc'); + + // UserB should see only their own doc + expect(jsonB!.data).toHaveLength(1); + expect(jsonB!.data[0].title).toBe('UserB Own Doc'); + }); + + test('should filter by status correctly across all statuses', async ({ request }) => { + const { user: userC } = await seedUser(); + + // Seed all three statuses with 2 docs each plus noise from received docs + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Draft 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Draft 2' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Pending 1' }, + }); + await seedPendingDocument(userA, teamA.id, [userC], { + createDocumentOptions: { title: 'Pending 2' }, + }); + await seedCompletedDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Completed 1' }, + }); + await seedCompletedDocument(userA, teamA.id, [userC], { + createDocumentOptions: { title: 'Completed 2' }, + }); + + const { json: draftResults } = await findDocuments(request, tokenA, { status: 'DRAFT' }); + expect(draftResults!.data).toHaveLength(2); + expect(draftResults!.data.every((d) => d.status === DocumentStatus.DRAFT)).toBe(true); + + const { json: pendingResults } = await findDocuments(request, tokenA, { status: 'PENDING' }); + expect(pendingResults!.data).toHaveLength(2); + expect(pendingResults!.data.every((d) => d.status === DocumentStatus.PENDING)).toBe(true); + + const { json: completedResults } = await findDocuments(request, tokenA, { + status: 'COMPLETED', + }); + expect(completedResults!.data).toHaveLength(2); + expect(completedResults!.data.every((d) => d.status === DocumentStatus.COMPLETED)).toBe(true); + }); + + test('should paginate correctly', async ({ request }) => { + // Create 5 documents + for (let i = 0; i < 5; i++) { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: `Paginated Doc ${i}` }, + }); + } + + // Also seed noise docs for userB to ensure isolation across pages + for (let i = 0; i < 3; i++) { + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: `UserB Noise Doc ${i}` }, + }); + } + + const { json: page1 } = await findDocuments(request, tokenA, { page: '1', perPage: '2' }); + expect(page1!.data).toHaveLength(2); + expect(page1!.count).toBe(5); + expect(page1!.currentPage).toBe(1); + expect(page1!.totalPages).toBe(3); + expect(page1!.perPage).toBe(2); + + const { json: page2 } = await findDocuments(request, tokenA, { page: '2', perPage: '2' }); + expect(page2!.data).toHaveLength(2); + expect(page2!.currentPage).toBe(2); + + const { json: page3 } = await findDocuments(request, tokenA, { page: '3', perPage: '2' }); + expect(page3!.data).toHaveLength(1); + expect(page3!.currentPage).toBe(3); + + // Ensure no duplicates across pages and no B docs leaked + const allTitles = [ + ...page1!.data.map((d) => d.title), + ...page2!.data.map((d) => d.title), + ...page3!.data.map((d) => d.title), + ]; + const uniqueTitles = new Set(allTitles); + expect(uniqueTitles.size).toBe(5); + expect(allTitles.every((t) => t.startsWith('Paginated Doc'))).toBe(true); + }); + + test('should search by document title and exclude non-matching docs', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Quarterly Report 2024' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Annual Budget Plan' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Monthly Summary' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: 'Quarterly' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Quarterly Report 2024'); + }); + + test('should search by recipient email and not return docs with different recipients', async ({ + request, + }) => { + const { user: userC } = await seedUser(); + + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Doc with Recipient B' }, + }); + await seedPendingDocument(userA, teamA.id, [userC], { + createDocumentOptions: { title: 'Doc with Recipient C' }, + }); + await seedPendingDocument(userA, teamA.id, [userB, userC], { + createDocumentOptions: { title: 'Doc with Both Recipients' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: userB.email }); + // Should find the doc with B and the doc with both, but not the doc with only C + expect(json!.data).toHaveLength(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Doc with Recipient B'); + expect(titles).toContain('Doc with Both Recipients'); + expect(titles).not.toContain('Doc with Recipient C'); + }); + + test('should search case-insensitively', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Important Contract' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Other Document' }, + }); + + const { json: lowerCase } = await findDocuments(request, tokenA, { + query: 'important contract', + }); + expect(lowerCase!.data).toHaveLength(1); + expect(lowerCase!.data[0].title).toBe('Important Contract'); + + const { json: upperCase } = await findDocuments(request, tokenA, { + query: 'IMPORTANT CONTRACT', + }); + expect(upperCase!.data).toHaveLength(1); + expect(upperCase!.data[0].title).toBe('Important Contract'); + }); + + test('should order by createdAt descending by default', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Third Created' }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.data).toHaveLength(3); + expect(json!.data[0].title).toBe('Third Created'); + expect(json!.data[1].title).toBe('Second Created'); + expect(json!.data[2].title).toBe('First Created'); + }); + + test('should support ascending order', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Third Created' }, + }); + + const { json } = await findDocuments(request, tokenA, { + orderByColumn: 'createdAt', + orderByDirection: 'asc', + }); + + expect(json!.data[0].title).toBe('First Created'); + expect(json!.data[1].title).toBe('Second Created'); + expect(json!.data[2].title).toBe('Third Created'); + }); + + test('owner should see all recipient tokens on their documents', async ({ request }) => { + // Full token masking (non-owner sees masked tokens) can't be tested via API + // since only ADMIN/MANAGER can create tokens and they have full visibility. + // This test verifies the owner sees all tokens; masking is tested in the UI file. + const { user: recipient1 } = await seedUser(); + const { user: recipient2 } = await seedUser(); + + await seedPendingDocument(userA, teamA.id, [recipient1, recipient2], { + createDocumentOptions: { title: 'Token Visibility Test' }, + }); + + const { json } = await findDocuments(request, tokenA); + const doc = json!.data.find((d) => d.title === 'Token Visibility Test'); + expect(doc).toBeDefined(); + expect(doc!.recipients.length).toBe(2); + // Owner should see all recipient tokens (not masked) + for (const r of doc!.recipients) { + expect(r.token).not.toBe(''); + } + }); + + test('should only show root-level documents when no folderId is provided', async ({ + request, + }) => { + const folder = await prisma.folder.create({ + data: { + name: 'Test Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Root Document 1', folderId: null }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Root Document 2', folderId: null }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Foldered Document 1', folderId: folder.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Foldered Document 2', folderId: folder.id }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Root Document 1'); + expect(titles).toContain('Root Document 2'); + expect(titles).not.toContain('Foldered Document 1'); + expect(titles).not.toContain('Foldered Document 2'); + }); + + test('should filter by folderId and not show root or other folder docs', async ({ request }) => { + const folder1 = await prisma.folder.create({ + data: { + name: 'Folder 1', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + const folder2 = await prisma.folder.create({ + data: { + name: 'Folder 2', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Root Document', folderId: null }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Folder1 Document', folderId: folder1.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Folder2 Document', folderId: folder2.id }, + }); + + const { json } = await findDocuments(request, tokenA, { folderId: folder1.id }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Folder1 Document'); + }); + + test('should return correct response schema fields', async ({ request }) => { + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Schema Check Doc' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Schema Check Draft' }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.count).toBe(2); + expect(json!.currentPage).toBeDefined(); + expect(json!.perPage).toBeDefined(); + expect(json!.totalPages).toBeDefined(); + + const doc = json!.data.find((d) => d.title === 'Schema Check Doc')!; + expect(doc.id).toBeDefined(); + expect(doc.status).toBe(DocumentStatus.PENDING); + expect(doc.createdAt).toBeDefined(); + expect(doc.updatedAt).toBeDefined(); + expect(doc.userId).toBe(userA.id); + expect(doc.teamId).toBe(teamA.id); + expect(doc.user).toBeDefined(); + expect(doc.user.id).toBe(userA.id); + expect(doc.user.email).toBe(userA.email); + expect(doc.recipients).toHaveLength(1); + expect(doc.recipients[0].email).toBe(userB.email); + }); + + test('should not return deleted documents but should return non-deleted ones', async ({ + request, + }) => { + const deletedDoc = await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Deleted Document' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Active Document 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Active Document 2' }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Active Document 1'); + expect(titles).toContain('Active Document 2'); + expect(titles).not.toContain('Deleted Document'); + }); + + test('should search by externalId', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { + title: 'External ID Doc', + externalId: 'EXT-12345-UNIQUE', + }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Other Doc 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Other Doc 2' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: 'EXT-12345-UNIQUE' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('External ID Doc'); + }); + + test('should search by recipient name', async ({ request }) => { + const { user: recipient } = await seedUser({ name: 'Unique Recipient Name' }); + const { user: otherRecipient } = await seedUser({ name: 'Other Person' }); + + await seedPendingDocument(userA, teamA.id, [recipient], { + createDocumentOptions: { title: 'Doc for Unique Person' }, + }); + await seedPendingDocument(userA, teamA.id, [otherRecipient], { + createDocumentOptions: { title: 'Doc for Other Person' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: 'Unique Recipient' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Doc for Unique Person'); + }); +}); + +test.describe('Find Documents API - Team Context', () => { + test('should return team documents for team members and exclude non-team docs', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + // Team docs + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Team Doc 1' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Doc 2' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Doc 3' }, + }, + ]); + + // Non-team docs (noise - should NOT appear) + await seedDocuments([ + { + sender: outsideUser, + teamId: outsideTeam.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Outside Draft' }, + }, + { + sender: outsideUser, + teamId: outsideTeam.id, + recipients: [member], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Outside Completed with Member as Recipient' }, + }, + ]); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'member-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, memberToken); + expect(json!.data.length).toBe(3); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Doc 1'); + expect(titles).toContain('Team Doc 2'); + expect(titles).toContain('Team Doc 3'); + expect(titles).not.toContain('Outside Draft'); + }); + + test('should NOT leak team documents to non-members', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + // Team docs + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Secret Team Draft' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Secret Team Completed' }, + }, + ]); + + // Outside user's own docs (positive control) + await seedDraftDocument(outsideUser, outsideTeam.id, [], { + createDocumentOptions: { title: 'Outside Own Doc' }, + }); + + const { token: outsideToken } = await createApiToken({ + userId: outsideUser.id, + teamId: outsideTeam.id, + tokenName: 'outside-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, outsideToken); + const titles = json!.data.map((d) => d.title); + // Outside user should see their own doc but not team docs + expect(titles).toContain('Outside Own Doc'); + expect(titles).not.toContain('Secret Team Draft'); + expect(titles).not.toContain('Secret Team Completed'); + }); + + test('should NOT show documents from other teams', async ({ request }) => { + const { team: teamX, owner: ownerX } = await seedTeam(); + const { team: teamY, owner: ownerY } = await seedTeam(); + + // Multiple docs in each team + await seedDocuments([ + { + sender: ownerX, + teamId: teamX.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team X Completed' }, + }, + { + sender: ownerX, + teamId: teamX.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team X Draft' }, + }, + { + sender: ownerY, + teamId: teamY.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Y Completed' }, + }, + { + sender: ownerY, + teamId: teamY.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Y Draft' }, + }, + ]); + + const { token: tokenX } = await createApiToken({ + userId: ownerX.id, + teamId: teamX.id, + tokenName: 'teamX-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, tokenX); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team X Completed'); + expect(titles).toContain('Team X Draft'); + expect(titles).not.toContain('Team Y Completed'); + expect(titles).not.toContain('Team Y Draft'); + expect(json!.count).toBe(2); + }); + + test('should enforce visibility across admin and manager levels with adequate data', async ({ + request, + }) => { + // Note: MEMBER role cannot create API tokens (requires MANAGE_TEAM permission). + // MEMBER visibility is tested in the UI test file instead. + const { team, owner } = await seedTeam(); + + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Seed 2 docs per visibility level (6 total) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Everyone Doc 1', visibility: DocumentVisibility.EVERYONE }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Everyone Doc 2', visibility: DocumentVisibility.EVERYONE }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Doc 1', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Doc 2', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Admin Doc 1', visibility: DocumentVisibility.ADMIN }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Admin Doc 2', visibility: DocumentVisibility.ADMIN }, + }, + ]); + + // Admin sees all 6 + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'admin-token', + expiresIn: null, + }); + const { json: adminJson } = await findDocuments(request, adminToken); + expect(adminJson!.count).toBe(6); + + // Manager sees 4 (Everyone + Manager) + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + const { json: managerJson } = await findDocuments(request, managerToken); + expect(managerJson!.count).toBe(4); + const managerTitles = managerJson!.data.map((d) => d.title); + expect(managerTitles).toContain('Everyone Doc 1'); + expect(managerTitles).toContain('Manager Doc 1'); + expect(managerTitles).not.toContain('Admin Doc 1'); + }); + + test('document owner should see their document regardless of visibility even when other restricted docs are hidden', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + // Use MANAGER (can create tokens but can't see ADMIN-vis docs by default) + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Manager creates an ADMIN-only doc (they should still see it as owner) + // Owner creates an ADMIN-only doc (manager should NOT see this one) + await seedDocuments([ + { + sender: manager, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Owned Admin Vis', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Owner Admin Vis (hidden from manager)', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Vis Control', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, managerToken); + const titles = json!.data.map((d) => d.title); + // Manager sees their own ADMIN doc + EVERYONE + MANAGER_AND_ABOVE docs, but NOT the owner's ADMIN doc + expect(titles).toContain('Manager Owned Admin Vis'); + expect(titles).toContain('Everyone Vis Control'); + expect(titles).not.toContain('Owner Admin Vis (hidden from manager)'); + }); + + test('recipient should see document regardless of visibility even when other restricted docs are hidden', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + // Use MANAGER (can create tokens but can't see ADMIN-vis docs by default) + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Admin doc with manager as recipient (manager should see despite ADMIN visibility) + // Admin doc WITHOUT manager as recipient (manager should NOT see) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [manager], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc with Manager Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc without Manager', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc Control', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, managerToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Admin Doc with Manager Recipient'); + expect(titles).toContain('Everyone Doc Control'); + expect(titles).not.toContain('Admin Doc without Manager'); + }); +}); + +test.describe('Find Documents API - Team with Team Email', () => { + test('should show documents sent by team email and received by team email, but not external noise', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-email-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + const { user: externalUser2, team: externalTeam2 } = await seedUser(); + + // Doc owned by team + await seedPendingDocument(owner, team.id, [externalUser], { + createDocumentOptions: { title: 'Team Owned Pending' }, + }); + + // Doc sent TO team email (external sender) + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Received by Team Email' }, + }); + await seedCompletedDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Completed for Team Email' }, + }); + + // Draft sent to team email (should NOT show) + await seedDraftDocument(externalUser2, externalTeam2.id, [teamEmail], { + createDocumentOptions: { title: 'Draft To Team Email (hidden)' }, + }); + + // Doc between two external users (noise) + await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], { + createDocumentOptions: { title: 'External Noise Doc' }, + }); + + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'admin-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, adminToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Owned Pending'); + expect(titles).toContain('Received by Team Email'); + expect(titles).toContain('Completed for Team Email'); + expect(titles).not.toContain('Draft To Team Email (hidden)'); + expect(titles).not.toContain('External Noise Doc'); + }); + + test('team email documents should respect visibility rules with adequate controls', async ({ + request, + }) => { + const { team } = await seedTeam(); + + const teamEmail = `team-vis-email-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + const { user: externalUser, team: externalTeam } = await seedUser(); + + // External user sends admin-only doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { + title: 'Admin Email Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // External user sends everyone-visible doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { + title: 'Everyone Email Doc', + visibility: DocumentVisibility.EVERYONE, + }, + }); + + // Admin should see both + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'admin-token', + expiresIn: null, + }); + const { json: adminJson } = await findDocuments(request, adminToken); + const adminTitles = adminJson!.data.map((d) => d.title); + expect(adminTitles).toContain('Admin Email Doc'); + expect(adminTitles).toContain('Everyone Email Doc'); + + // Manager should see both (Everyone + Manager_and_above, and ADMIN email doc should not be visible) + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + const { json: managerJson } = await findDocuments(request, managerToken); + const managerTitles = managerJson!.data.map((d) => d.title); + expect(managerTitles).toContain('Everyone Email Doc'); + expect(managerTitles).not.toContain('Admin Email Doc'); + }); +}); + +test.describe('Find Documents API - Deleted Document Handling', () => { + test('should not show soft-deleted documents for owner but show non-deleted ones', async ({ + request, + }) => { + const { user, team } = await seedUser(); + + const deletedDoc = await seedPendingDocument(user, team.id, [], { + createDocumentOptions: { title: 'Soft Deleted by Owner' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + await seedPendingDocument(user, team.id, [], { + createDocumentOptions: { title: 'Still Active Doc 1' }, + }); + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Still Active Doc 2' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Still Active Doc 1'); + expect(titles).toContain('Still Active Doc 2'); + expect(titles).not.toContain('Soft Deleted by Owner'); + }); + + test('should not show documents where owner soft-deleted their copy in personal context', async ({ + request, + }) => { + // In personal context, documentDeletedAt on recipient hides the doc for that user. + // Note: the v2 API scopes to a team, so we test this by having the owner + // soft-delete a doc from their own personal team. + const { user, team } = await seedUser(); + + const deletedDoc = await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Owner Soft Deleted' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + // Non-deleted doc (positive control) + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Owner Active Doc' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Owner Active Doc'); + expect(titles).not.toContain('Owner Soft Deleted'); + }); + + test('should not show deleted team documents for any team member but show non-deleted ones', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + const deletedDoc = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { title: 'Deleted Team Doc' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + await seedBlankDocument(owner, team.id, { + createDocumentOptions: { title: 'Active Team Doc 1' }, + }); + await seedBlankDocument(owner, team.id, { + createDocumentOptions: { title: 'Active Team Doc 2' }, + }); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'member-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, memberToken); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Active Team Doc 1'); + expect(titles).toContain('Active Team Doc 2'); + expect(titles).not.toContain('Deleted Team Doc'); + }); +}); + +test.describe('Find Documents API - Edge Cases', () => { + test('should handle empty search query gracefully', async ({ request }) => { + const { user, team } = await seedUser(); + + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Test Doc 1' }, + }); + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Test Doc 2' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token, { query: '' }); + expect(json!.data).toHaveLength(2); + }); + + test('should handle page beyond total pages', async ({ request }) => { + const { user, team } = await seedUser(); + + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Single Doc' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token, { page: '999', perPage: '10' }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(1); + expect(json!.totalPages).toBe(1); + }); + + test('should reject unauthenticated requests', async ({ request }) => { + const res = await request.get(`${baseUrl}/document`, { + headers: {}, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject invalid API tokens', async ({ request }) => { + const res = await request.get(`${baseUrl}/document`, { + headers: { Authorization: 'Bearer invalid_token_here' }, + }); + + expect(res.ok()).toBeFalsy(); + }); + + test('personal documents should not appear in team context even with adequate team data', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + const teamMember = await seedTeamMember({ + teamId: team.id, + role: TeamMemberRole.ADMIN, + }); + + // Find member's personal team (seedUser creates an org with ownerUserId set) + const teamMemberOrg = await prisma.organisation.findFirstOrThrow({ + where: { + ownerUserId: teamMember.id, + }, + include: { + teams: true, + }, + }); + + const memberPersonalTeamId = teamMemberOrg.teams[0].id; + + // Multiple personal docs + await seedDraftDocument(teamMember, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Personal Doc 1' }, + }); + await seedDraftDocument(teamMember, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Personal Doc 2' }, + }); + + // Multiple team docs + await seedDraftDocument(teamMember, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Member 1' }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Owner' }, + }); + + const { token: teamToken } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'team-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, teamToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Doc by Member 1'); + expect(titles).toContain('Team Doc by Owner'); + expect(titles).not.toContain('Personal Doc 1'); + expect(titles).not.toContain('Personal Doc 2'); + expect(json!.count).toBe(2); + }); +}); + +// ─── Adversarial / Parameter Manipulation Tests ────────────────────────────── +// These tests target attack vectors where an authenticated user attempts to +// access data they shouldn't by manipulating request parameters. +// Session-based tests hit the tRPC endpoint with GET (queries require GET, not POST) +// and pass the x-team-id header to simulate header spoofing. + +const trpcQuery = async ( + page: import('@playwright/test').Page, + route: string, + teamId: number, + input: Record = {}, +) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: input })); + const url = `${WEBAPP_BASE_URL}/api/trpc/${route}?input=${inputParam}`; + + return page.context().request.get(url, { + headers: { + 'x-team-id': String(teamId), + }, + }); +}; + +test.describe('Find Documents API - Adversarial: x-team-id Header Spoofing', () => { + test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({ + page, + }) => { + // Setup: two separate teams with documents + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'Secret TeamA Doc 1' }, + }); + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'Secret TeamA Doc 2' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Own Doc' }, + }); + + // Sign in as ownerB (who has NO access to teamA) + await apiSignin({ page, email: ownerB.email }); + + // Attempt to query findDocumentsInternal with x-team-id pointing to teamA + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + page: 1, + perPage: 100, + }); + + // Should be rejected — ownerB is not a member of teamA + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should return only own team data when user provides their legitimate x-team-id (positive control)', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Legit Doc' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Legit Doc' }, + }); + + // Sign in as ownerA + await apiSignin({ page, email: ownerA.email }); + + // Query with legitimate x-team-id + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + expect(titles).toContain('TeamA Legit Doc'); + expect(titles).not.toContain('TeamB Legit Doc'); + }); + + test('team member should not access another team via x-team-id even if they belong to a different team', async ({ + page, + }) => { + // User belongs to teamA but NOT teamB — tries to access teamB via header + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const member = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Secret' }, + }); + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Doc' }, + }); + + // Sign in as member (belongs to teamA only) + await apiSignin({ page, email: member.email }); + + // Attempt to access teamB + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamB.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); +}); + +test.describe('Find Documents API - Adversarial: Cross-Team folderId', () => { + test('should NOT return documents from another team when folderId belongs to that team', async ({ + request, + }) => { + // Setup: two teams each with a folder and documents + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + const folderA = await prisma.folder.create({ + data: { + name: 'Team A Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + const folderB = await prisma.folder.create({ + data: { + name: 'Team B Folder', + teamId: teamB.id, + userId: userB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Folder Doc', folderId: folderA.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Root Doc' }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Folder Doc', folderId: folderB.id }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries to query with teamB's folderId — should return empty, not teamB's docs + const { json } = await findDocuments(request, tokenA, { folderId: folderB.id }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: querying own folder works + const { json: ownFolder } = await findDocuments(request, tokenA, { folderId: folderA.id }); + expect(ownFolder!.data).toHaveLength(1); + expect(ownFolder!.data[0].title).toBe('TeamA Folder Doc'); + }); + + test('cross-team folderId via session/tRPC should also return empty', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const folderB = await prisma.folder.create({ + data: { + name: 'Target Folder', + teamId: teamB.id, + userId: ownerB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'Folder Target Doc', folderId: folderB.id }, + }); + + // Sign in as ownerA, request with own team but teamB's folderId + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + folderId: folderB.id, + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + expect(docs).toHaveLength(0); + }); +}); + +test.describe('Find Documents API - Adversarial: Cross-Team senderIds', () => { + test('should NOT return documents when senderIds contains users from another team', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + // Both teams have documents + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Doc by OwnerA' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Doc by OwnerB' }, + }); + + // Sign in as ownerA, try to use senderIds with ownerB's userId + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + senderIds: [ownerB.id], + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + // senderIds narrows within team scope — ownerB is not on teamA, so no results + expect(docs).toHaveLength(0); + + // Positive control: senderIds with own userId returns own docs + const res2 = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + senderIds: [ownerA.id], + page: 1, + perPage: 100, + }); + + expect(res2.ok()).toBeTruthy(); + const data2 = await res2.json(); + const docs2 = data2.result.data.json.data; + expect(docs2).toHaveLength(1); + expect(docs2[0].title).toBe('TeamA Doc by OwnerA'); + }); + + test('senderIds with mix of valid and cross-team userIds should only return matching team docs', async ({ + page, + }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: outsider } = await seedUser(); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Owner Doc' }, + }); + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Member Doc' }, + }); + + // Find outsider's personal team + const outsiderOrg = await prisma.organisation.findFirstOrThrow({ + where: { ownerUserId: outsider.id }, + include: { teams: true }, + }); + const outsiderTeamId = outsiderOrg.teams[0].id; + + await seedDraftDocument(outsider, outsiderTeamId, [], { + createDocumentOptions: { title: 'Outsider Doc' }, + }); + + await apiSignin({ page, email: owner.email }); + + // Include outsider.id in senderIds — should be silently ignored (no results from them) + const res = await trpcQuery(page, 'document.findDocumentsInternal', team.id, { + senderIds: [member.id, outsider.id], + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + // Only member doc should appear — outsider's docs are on a different team + expect(titles).toContain('Member Doc'); + expect(titles).not.toContain('Owner Doc'); // owner not in senderIds + expect(titles).not.toContain('Outsider Doc'); + expect(docs).toHaveLength(1); + }); +}); + +test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => { + test('should NOT return documents from another team when filtering by their templateId', async ({ + request, + }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + // Use a shared templateId integer (simulates a template that exists on teamB) + const fakeTemplateId = 999888; + + // Create a doc in teamB with this templateId + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { + title: 'TeamB Doc from Template', + templateId: fakeTemplateId, + }, + }); + + // Create a doc in teamA with a different templateId (positive control) + const teamATemplateId = 999777; + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { + title: 'TeamA Doc from Template', + templateId: teamATemplateId, + }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Regular Doc' }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries to filter by teamB's templateId — should return empty, not teamB's docs + const { json } = await findDocuments(request, tokenA, { + templateId: String(fakeTemplateId), + }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: own templateId returns own docs + const { json: ownTemplate } = await findDocuments(request, tokenA, { + templateId: String(teamATemplateId), + }); + expect(ownTemplate!.data).toHaveLength(1); + expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template'); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts new file mode 100644 index 000000000..50ed121ff --- /dev/null +++ b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts @@ -0,0 +1,1079 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { + DocumentStatus, + DocumentVisibility, + EnvelopeType, + TeamMemberRole, +} from '@documenso/prisma/client'; +import { + seedBlankDocument, + seedCompletedDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types'; + +import { apiSignin } from '../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +// Helper to make authenticated GET requests to the find envelopes endpoint. +const findEnvelopes = async ( + request: import('@playwright/test').APIRequestContext, + token: string, + params: Record = {}, +) => { + const searchParams = new URLSearchParams(params); + const url = `${baseUrl}/envelope${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + const res = await request.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return { + res, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + json: res.ok() ? ((await res.json()) as TFindEnvelopesResponse) : null, + }; +}; + +// ─── Expected Output Tests ─────────────────────────────────────────────────── + +test.describe('Find Envelopes API - Basic', () => { + let userA: User, teamA: Team, tokenA: string; + let userB: User, teamB: Team, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'tokenB', + expiresIn: null, + })); + }); + + test('should return empty results when no envelopes exist', async ({ request }) => { + const { json } = await findEnvelopes(request, tokenA); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + expect(json!.currentPage).toBe(1); + expect(json!.totalPages).toBe(0); + }); + + test('should return only envelopes owned by the user and not the other user', async ({ + request, + }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Doc' }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Doc' }, + }); + + const { json: jsonA } = await findEnvelopes(request, tokenA); + expect(jsonA!.data).toHaveLength(1); + expect(jsonA!.data[0].title).toBe('UserA Doc'); + + const { json: jsonB } = await findEnvelopes(request, tokenB); + expect(jsonB!.data).toHaveLength(1); + expect(jsonB!.data[0].title).toBe('UserB Doc'); + }); + + test('should NOT leak envelopes between unrelated users', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Secret A1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Secret A2' }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'Secret B1' }, + }); + + const { json } = await findEnvelopes(request, tokenB); + const titles = json!.data.map((d) => d.title); + expect(titles).not.toContain('Secret A1'); + expect(titles).not.toContain('Secret A2'); + expect(titles).toContain('Secret B1'); + expect(json!.count).toBe(1); + }); + + test('should filter by status correctly', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Draft Doc' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Pending Doc' }, + }); + await seedCompletedDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Completed Doc' }, + }); + + // DRAFT only + const { json: draftJson } = await findEnvelopes(request, tokenA, { status: 'DRAFT' }); + expect(draftJson!.data.every((d) => d.status === DocumentStatus.DRAFT)).toBe(true); + expect(draftJson!.data.some((d) => d.title === 'Draft Doc')).toBe(true); + expect(draftJson!.data.some((d) => d.title === 'Pending Doc')).toBe(false); + + // PENDING only + const { json: pendingJson } = await findEnvelopes(request, tokenA, { status: 'PENDING' }); + expect(pendingJson!.data.every((d) => d.status === DocumentStatus.PENDING)).toBe(true); + + // COMPLETED only + const { json: completedJson } = await findEnvelopes(request, tokenA, { status: 'COMPLETED' }); + expect(completedJson!.data.every((d) => d.status === DocumentStatus.COMPLETED)).toBe(true); + }); + + test('should filter by type (DOCUMENT vs TEMPLATE)', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'A Document', type: EnvelopeType.DOCUMENT }, + }); + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'A Template', type: EnvelopeType.TEMPLATE }, + }); + + const { json: docJson } = await findEnvelopes(request, tokenA, { type: 'DOCUMENT' }); + expect(docJson!.data.every((d) => d.type === EnvelopeType.DOCUMENT)).toBe(true); + expect(docJson!.data.some((d) => d.title === 'A Document')).toBe(true); + expect(docJson!.data.some((d) => d.title === 'A Template')).toBe(false); + + const { json: templateJson } = await findEnvelopes(request, tokenA, { type: 'TEMPLATE' }); + expect(templateJson!.data.every((d) => d.type === EnvelopeType.TEMPLATE)).toBe(true); + expect(templateJson!.data.some((d) => d.title === 'A Template')).toBe(true); + expect(templateJson!.data.some((d) => d.title === 'A Document')).toBe(false); + }); + + test('should paginate correctly', async ({ request }) => { + // Create 5 docs, paginate with perPage=2 + for (let i = 1; i <= 5; i++) { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: `Paginated Doc ${i}` }, + }); + } + + const { json: page1 } = await findEnvelopes(request, tokenA, { + page: '1', + perPage: '2', + }); + expect(page1!.data).toHaveLength(2); + expect(page1!.count).toBe(5); + expect(page1!.totalPages).toBe(3); + expect(page1!.currentPage).toBe(1); + + const { json: page3 } = await findEnvelopes(request, tokenA, { + page: '3', + perPage: '2', + }); + expect(page3!.data).toHaveLength(1); + expect(page3!.currentPage).toBe(3); + + // Page beyond total + const { json: pageBeyond } = await findEnvelopes(request, tokenA, { + page: '10', + perPage: '2', + }); + expect(pageBeyond!.data).toHaveLength(0); + }); + + test('should search by title', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Annual Budget Report' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Quarterly Review' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { query: 'Budget' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Annual Budget Report'); + }); + + test('should search by externalId', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'External Doc', externalId: 'ext-abc-123' }, + }); + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'Other Doc', externalId: 'ext-xyz-789' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { query: 'abc-123' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('External Doc'); + }); + + test('should search by recipient email and name', async ({ request }) => { + const { user: recipientUser } = await seedUser(); + + await seedPendingDocument(userA, teamA.id, [recipientUser], { + createDocumentOptions: { title: 'Doc with Specific Recipient' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Doc without Recipients' }, + }); + + // Search by recipient email + const { json: emailSearch } = await findEnvelopes(request, tokenA, { + query: recipientUser.email, + }); + expect(emailSearch!.data).toHaveLength(1); + expect(emailSearch!.data[0].title).toBe('Doc with Specific Recipient'); + + // Search by recipient name + if (recipientUser.name) { + const { json: nameSearch } = await findEnvelopes(request, tokenA, { + query: recipientUser.name, + }); + expect(nameSearch!.data.some((d) => d.title === 'Doc with Specific Recipient')).toBe(true); + } + }); + + test('should search case-insensitively', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UPPERCASE TITLE' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { query: 'uppercase title' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('UPPERCASE TITLE'); + }); + + test('should order by createdAt descending by default', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + + const { json } = await findEnvelopes(request, tokenA); + expect(json!.data).toHaveLength(2); + expect(json!.data[0].title).toBe('Second Created'); + expect(json!.data[1].title).toBe('First Created'); + }); + + test('should support ascending order', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { + orderByColumn: 'createdAt', + orderByDirection: 'asc', + }); + expect(json!.data[0].title).toBe('First Created'); + expect(json!.data[1].title).toBe('Second Created'); + }); + + test('should filter by folderId and show root-level when no folderId', async ({ request }) => { + const folder = await prisma.folder.create({ + data: { + name: 'Test Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'In Folder', folderId: folder.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'At Root' }, + }); + + // No folderId → root-level only (folderId: null) + const { json: rootJson } = await findEnvelopes(request, tokenA); + const rootTitles = rootJson!.data.map((d) => d.title); + expect(rootTitles).toContain('At Root'); + expect(rootTitles).not.toContain('In Folder'); + + // With folderId → only docs in that folder + const { json: folderJson } = await findEnvelopes(request, tokenA, { folderId: folder.id }); + expect(folderJson!.data).toHaveLength(1); + expect(folderJson!.data[0].title).toBe('In Folder'); + }); + + test('should not return deleted envelopes', async ({ request }) => { + const doc = await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Deleted Doc' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Active Doc' }, + }); + + // Soft-delete + await prisma.envelope.update({ + where: { id: doc.id }, + data: { deletedAt: new Date() }, + }); + + const { json } = await findEnvelopes(request, tokenA); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Active Doc'); + expect(titles).not.toContain('Deleted Doc'); + }); + + test('should return correct response schema fields', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Schema Check' }, + }); + + const { json } = await findEnvelopes(request, tokenA); + const envelope = json!.data[0]; + + // Pagination fields + expect(json!.count).toBeGreaterThan(0); + expect(json!.currentPage).toBe(1); + expect(json!.perPage).toBe(10); + expect(json!.totalPages).toBeGreaterThan(0); + + // Envelope fields + expect(envelope.id).toBeDefined(); + expect(envelope.title).toBe('Schema Check'); + expect(envelope.status).toBe(DocumentStatus.DRAFT); + expect(envelope.type).toBe(EnvelopeType.DOCUMENT); + expect(envelope.createdAt).toBeDefined(); + expect(envelope.updatedAt).toBeDefined(); + expect(envelope.deletedAt).toBeNull(); + + // Included relations + expect(envelope.user).toBeDefined(); + expect(envelope.user.id).toBe(userA.id); + expect(envelope.user.email).toBe(userA.email); + expect(envelope.recipients).toBeDefined(); + expect(envelope.team).toBeDefined(); + }); + + test('should reject unauthenticated requests', async ({ request }) => { + const { res } = await findEnvelopes(request, ''); + expect(res.ok()).toBeFalsy(); + }); + + test('should reject invalid API tokens', async ({ request }) => { + const { res } = await findEnvelopes(request, 'invalid-token-abc123'); + expect(res.ok()).toBeFalsy(); + }); +}); + +// ─── Token Masking ─────────────────────────────────────────────────────────── + +test.describe('Find Envelopes API - Token Masking', () => { + test('owner should see all recipient tokens on their envelopes', async ({ request }) => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'ownerToken', + expiresIn: null, + }); + + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Owner Token Test' }, + }); + + const { json } = await findEnvelopes(request, token); + const doc = json!.data.find((d) => d.title === 'Owner Token Test'); + expect(doc).toBeDefined(); + expect(doc!.recipients.length).toBeGreaterThan(0); + // Owner sees actual token values (non-empty) + doc!.recipients.forEach((r) => { + expect(r.token).not.toBe(''); + }); + }); + + test('non-owner team member should have recipient tokens masked', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: recipient } = await seedUser(); + + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Masked Token Test' }, + }); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'memberToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, memberToken); + const doc = json!.data.find((d) => d.title === 'Masked Token Test'); + expect(doc).toBeDefined(); + expect(doc!.recipients.length).toBeGreaterThan(0); + // Non-owner should see masked tokens (empty string) + doc!.recipients.forEach((r) => { + expect(r.token).toBe(''); + }); + }); +}); + +// ─── Team Context & Visibility ─────────────────────────────────────────────── + +test.describe('Find Envelopes API - Team Context', () => { + // Regression test: findEnvelopes previously had `{ userId }` as a top-level OR branch with no + // teamId constraint, so personal docs leaked into team context. Fixed in the Kysely refactor. + test('should return team envelopes for team members and exclude personal docs', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + // Team docs + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Owner' }, + }); + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Member' }, + }); + + // Member's personal doc — should NOT appear in team context + const memberOrg = await prisma.organisation.findFirstOrThrow({ + where: { ownerUserId: member.id }, + include: { teams: true }, + }); + const memberPersonalTeamId = memberOrg.teams[0].id; + await seedDraftDocument(member, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Member Personal Doc' }, + }); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'memberTeamToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, memberToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Doc by Owner'); + expect(titles).toContain('Team Doc by Member'); + expect(titles).not.toContain('Member Personal Doc'); + }); + + test('should NOT show envelopes from other teams', async ({ request }) => { + const { team: teamX, owner: ownerX } = await seedTeam(); + const { team: teamY, owner: ownerY } = await seedTeam(); + + await seedDraftDocument(ownerX, teamX.id, [], { + createDocumentOptions: { title: 'TeamX Doc' }, + }); + await seedDraftDocument(ownerY, teamY.id, [], { + createDocumentOptions: { title: 'TeamY Doc' }, + }); + + const { token: tokenX } = await createApiToken({ + userId: ownerX.id, + teamId: teamX.id, + tokenName: 'tokenX', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, tokenX); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamX Doc'); + expect(titles).not.toContain('TeamY Doc'); + }); + + test('should NOT leak team envelopes to non-members', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { user: outsider, team: outsiderTeam } = await seedUser(); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Team Secret Doc' }, + }); + + const { token: outsiderToken } = await createApiToken({ + userId: outsider.id, + teamId: outsiderTeam.id, + tokenName: 'outsiderToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, outsiderToken); + const titles = json!.data.map((d) => d.title); + expect(titles).not.toContain('Team Secret Doc'); + }); + + test('should enforce visibility: ADMIN sees all levels', async ({ request }) => { + const { team, owner } = await seedTeam(); + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Admin Vis', visibility: DocumentVisibility.ADMIN }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Manager Vis', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Everyone Vis', visibility: DocumentVisibility.EVERYONE }, + }); + + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'adminToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, adminToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Admin Vis'); + expect(titles).toContain('Manager Vis'); + expect(titles).toContain('Everyone Vis'); + }); + + test('should enforce visibility: MANAGER cannot see ADMIN-only docs', async ({ request }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Admin Only', visibility: DocumentVisibility.ADMIN }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Manager Visible', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Everyone Visible', + visibility: DocumentVisibility.EVERYONE, + }, + }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + expect(titles).not.toContain('Admin Only'); + expect(titles).toContain('Manager Visible'); + expect(titles).toContain('Everyone Visible'); + }); + + test('document owner should see their doc regardless of visibility', async ({ request }) => { + const { team, owner } = await seedTeam(); + + // Another user creates an ADMIN-only doc — owner shouldn't see it + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + await seedDraftDocument(admin, team.id, [], { + createDocumentOptions: { + title: 'Admin Created Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // Owner creates their own ADMIN-only doc — should see it because they own it + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Owner ADMIN Doc', visibility: DocumentVisibility.ADMIN }, + }); + + // Owner is implicitly ADMIN of their own team, so let's test with a MANAGER member + // who owns an ADMIN-visibility doc + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + await seedDraftDocument(manager, team.id, [], { + createDocumentOptions: { + title: 'Manager Own ADMIN Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + // Manager should see their own ADMIN doc (owner override) + expect(titles).toContain('Manager Own ADMIN Doc'); + // Manager should NOT see admin's ADMIN doc (visibility restricted) + expect(titles).not.toContain('Admin Created Doc'); + }); + + test('being a recipient does not override ADMIN visibility in the API', async ({ request }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Owner creates an ADMIN-only doc with manager as recipient + await seedPendingDocument(owner, team.id, [manager], { + createDocumentOptions: { + title: 'ADMIN Doc With Manager Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // Owner creates another ADMIN-only doc WITHOUT manager as recipient + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'ADMIN Doc Without Manager', + visibility: DocumentVisibility.ADMIN, + }, + }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + // Unlike findDocuments (UI), the API does not let recipient status bypass visibility + expect(titles).not.toContain('ADMIN Doc With Manager Recipient'); + expect(titles).not.toContain('ADMIN Doc Without Manager'); + }); +}); + +// ─── Team with Team Email ──────────────────────────────────────────────────── + +test.describe('Find Envelopes API - Team Email', () => { + test('should include envelopes received by team email from external senders', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + const teamEmailAddr = `team-find-env-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmailAddr, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // Regular team doc (should be included) + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Regular Team Doc' }, + }); + + // Doc sent TO team email from external user (should be included — recipient match) + await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], { + createDocumentOptions: { title: 'Received by Team Email' }, + }); + + // External noise (should NOT be included) + const { user: externalUser2 } = await seedUser(); + await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], { + createDocumentOptions: { title: 'External Noise Doc' }, + }); + + const { token } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'ownerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, token); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Regular Team Doc'); + expect(titles).toContain('Received by Team Email'); + expect(titles).not.toContain('External Noise Doc'); + }); + + test('should NOT include external noise from other teams when team has team email', async ({ + request, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const teamEmailAddr = `team-noise-${teamA.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmailAddr, teamId: teamA.id }); + + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Doc' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB External Doc' }, + }); + + const { token: tokenA } = await createApiToken({ + userId: ownerA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, tokenA); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamA Doc'); + expect(titles).not.toContain('TeamB External Doc'); + }); + + test('team email received docs bypass visibility for managers', async ({ request }) => { + const { team, owner } = await seedTeam(); + const teamEmailAddr = `team-vis-env-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmailAddr, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // External user sends ADMIN-visibility doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], { + createDocumentOptions: { + title: 'External ADMIN to Team Email', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // External user sends EVERYONE-visibility doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], { + createDocumentOptions: { + title: 'External EVERYONE to Team Email', + visibility: DocumentVisibility.EVERYONE, + }, + }); + + // Regular team doc with ADMIN visibility (manager shouldn't see via visibility filter) + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Team ADMIN Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + + // Team email recipient filter bypasses visibility — both docs visible + expect(titles).toContain('External EVERYONE to Team Email'); + expect(titles).toContain('External ADMIN to Team Email'); + // Regular ADMIN doc should NOT be visible to manager (no bypass) + expect(titles).not.toContain('Team ADMIN Doc'); + }); +}); + +// ─── Adversarial / Parameter Manipulation Tests ────────────────────────────── + +const trpcQuery = async ( + page: import('@playwright/test').Page, + route: string, + teamId: number, + input: Record = {}, +) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: input })); + const url = `${WEBAPP_BASE_URL}/api/trpc/${route}?input=${inputParam}`; + + return page.context().request.get(url, { + headers: { + 'x-team-id': String(teamId), + }, + }); +}; + +test.describe('Find Envelopes API - Adversarial: x-team-id Header Spoofing', () => { + test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'Secret TeamA Envelope' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Envelope' }, + }); + + // Sign in as ownerB (NOT a member of teamA) + await apiSignin({ page, email: ownerB.email }); + + // Spoof x-team-id to teamA + const res = await trpcQuery(page, 'envelope.find', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should return only own team data when user provides legitimate x-team-id (positive control)', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Legit Envelope' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Legit Envelope' }, + }); + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'envelope.find', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + expect(titles).toContain('TeamA Legit Envelope'); + expect(titles).not.toContain('TeamB Legit Envelope'); + }); + + test('member of TeamA should not access TeamB via x-team-id', async ({ page }) => { + const { team: teamA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const member = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Secret' }, + }); + + await apiSignin({ page, email: member.email }); + + const res = await trpcQuery(page, 'envelope.find', teamB.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); +}); + +test.describe('Find Envelopes API - Adversarial: Cross-Team folderId', () => { + test('should NOT return envelopes from another team when folderId belongs to that team', async ({ + request, + }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + const folderA = await prisma.folder.create({ + data: { + name: 'TeamA Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + const folderB = await prisma.folder.create({ + data: { + name: 'TeamB Folder', + teamId: teamB.id, + userId: userB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Folder Env', folderId: folderA.id }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Folder Env', folderId: folderB.id }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries teamB's folderId — should return empty + const { json } = await findEnvelopes(request, tokenA, { folderId: folderB.id }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: own folderId works + const { json: ownFolder } = await findEnvelopes(request, tokenA, { folderId: folderA.id }); + expect(ownFolder!.data).toHaveLength(1); + expect(ownFolder!.data[0].title).toBe('TeamA Folder Env'); + }); + + test('cross-team folderId via session/tRPC should also return empty', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const folderB = await prisma.folder.create({ + data: { + name: 'Target Folder', + teamId: teamB.id, + userId: ownerB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'Target Folder Env', folderId: folderB.id }, + }); + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'envelope.find', teamA.id, { + folderId: folderB.id, + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + expect(docs).toHaveLength(0); + }); +}); + +test.describe('Find Envelopes API - Adversarial: Cross-Team templateId', () => { + test('should NOT return envelopes from another team when filtering by their templateId', async ({ + request, + }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + const fakeTemplateId = 888777; + const ownTemplateId = 888666; + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Template Env', templateId: fakeTemplateId }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Template Env', templateId: ownTemplateId }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries teamB's templateId — should return empty + const { json } = await findEnvelopes(request, tokenA, { + templateId: String(fakeTemplateId), + }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: own templateId works + const { json: ownTemplate } = await findEnvelopes(request, tokenA, { + templateId: String(ownTemplateId), + }); + expect(ownTemplate!.data).toHaveLength(1); + expect(ownTemplate!.data[0].title).toBe('TeamA Template Env'); + }); +}); + +// ─── Personal vs Team Isolation ────────────────────────────────────────────── + +test.describe('Find Envelopes API - Cross-User Isolation', () => { + test('other users personal envelopes should never appear regardless of context', async ({ + request, + }) => { + const { team } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: outsider, team: outsiderTeam } = await seedUser(); + + // Outsider creates personal doc + await seedDraftDocument(outsider, outsiderTeam.id, [], { + createDocumentOptions: { title: 'Outsider Personal Env' }, + }); + + // Member creates team doc + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Team Env' }, + }); + + // Query with team token — outsider's doc should never appear + const { token: teamToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'teamToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, teamToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Env'); + expect(titles).not.toContain('Outsider Personal Env'); + }); + + // Regression test: Same { userId } OR clause issue — user's team docs leaked into personal + // context. Fixed in the Kysely refactor. + test('team envelopes should not appear in personal context', async ({ request }) => { + const { team: orgTeam } = await seedTeam(); + // Add a member — seedTeamMember creates a separate user with their own personal team + const member = await seedTeamMember({ teamId: orgTeam.id, role: TeamMemberRole.ADMIN }); + + // Find the member's personal team + const memberOrg = await prisma.organisation.findFirstOrThrow({ + where: { ownerUserId: member.id }, + include: { teams: true }, + }); + const memberPersonalTeamId = memberOrg.teams[0].id; + + // Member creates a doc on the org team + await seedDraftDocument(member, orgTeam.id, [], { + createDocumentOptions: { title: 'Member Org Team Env' }, + }); + + // Member creates a doc on their personal team + await seedDraftDocument(member, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Member Personal Env' }, + }); + + // Query with member's personal team token + const { token: personalToken } = await createApiToken({ + userId: member.id, + teamId: memberPersonalTeamId, + tokenName: 'personalToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, personalToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Member Personal Env'); + // Org team doc should NOT appear in personal context + expect(titles).not.toContain('Member Org Team Env'); + }); +}); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts new file mode 100644 index 000000000..42bc69b13 --- /dev/null +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -0,0 +1,1217 @@ +import { expect, test } from '@playwright/test'; +import { + DocumentStatus, + DocumentVisibility, + OrganisationMemberRole, + TeamMemberRole, +} from '@prisma/client'; + +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { + seedCompletedDocument, + seedDocuments, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('Find Documents UI - Personal Context', () => { + test('should show all owned documents across statuses', async ({ page }) => { + const { user: owner, team, organisation } = await seedUser(); + const { user: recipient } = await seedUser(); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Personal Draft Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [recipient], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Personal Pending Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [recipient], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Personal Completed Doc' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 3); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + }); + + test('received documents from other teams should NOT appear in personal context', async ({ + page, + }) => { + // The UI always uses the team code path (findTeamDocumentsFilter) which filters by teamId. + // Documents sent TO a user by another user's team live on the sender's teamId, + // so they do NOT appear in the recipient's personal team context. + const { user: owner, team: ownerTeam } = await seedUser(); + const { user: sender, team: senderTeam } = await seedUser(); + + // Owner has their own doc (positive control — should appear) + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'Owner Own Draft' }, + }); + + // Sender sends docs to owner (these live on senderTeam, NOT ownerTeam) + await seedPendingDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Pending Doc' }, + }); + await seedCompletedDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Completed Doc' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${ownerTeam.url}/documents`, + }); + + // Only the owner's own doc should appear (received docs are on sender's team) + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Owner Own Draft' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Received Pending Doc', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'Received Completed Doc', exact: true }), + ).not.toBeVisible(); + }); + + test('should NOT show documents from other users', async ({ page }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Secret Document' }, + }); + + await apiSignin({ + page, + email: userA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 0); + await expect( + page.getByRole('link', { name: 'UserB Secret Document', exact: true }), + ).not.toBeVisible(); + }); + + test('personal context without team email should show 0 inbox', async ({ page }) => { + // The UI uses the team code path. Without a teamEmail, INBOX returns null (empty). + // Received docs from other teams don't appear in the user's personal team context. + const { user: owner, team: ownerTeam } = await seedUser(); + const { user: sender, team: senderTeam } = await seedUser(); + + // Sender sends a pending doc to owner (lives on sender's team, not owner's) + await seedPendingDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Inbox Document for Owner' }, + }); + + // Owner has their own doc (positive control) + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'Owner Draft Control' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${ownerTeam.url}/documents`, + }); + + // Inbox should be 0 since there's no team email and received docs are on sender's team + await checkDocumentTabCount(page, 'Inbox', 0); + // Owner's own doc should still show in All + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible(); + }); + + test('should filter documents by search query', async ({ page }) => { + const { user: owner, team } = await seedUser(); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Quarterly Report 2024' }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Annual Budget Plan' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Quarterly'); + await page.waitForURL(/query=Quarterly/); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Annual Budget Plan', exact: true }), + ).not.toBeVisible(); + }); + + test('should not show deleted documents', async ({ page }) => { + const { user: owner, team } = await seedUser(); + + const doc = await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Deleted Personal Doc' }, + }); + await prisma.envelope.update({ + where: { id: doc.id }, + data: { deletedAt: new Date() }, + }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Active Personal Doc' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Active Personal Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Deleted Personal Doc', exact: true }), + ).not.toBeVisible(); + }); + + test('should only show root-level documents when not in a folder', async ({ page }) => { + const { user: owner, team } = await seedUser(); + + const folder = await prisma.folder.create({ + data: { + name: 'My Folder', + teamId: team.id, + userId: owner.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Root Level Doc', folderId: null }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Folder Level Doc', folderId: folder.id }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Root Level Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Folder Level Doc', exact: true }), + ).not.toBeVisible(); + }); +}); + +test.describe('Find Documents UI - Team Context', () => { + test('should show team documents to all team members', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + // Seed multiple team docs across statuses + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Team Pending Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Draft Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Completed Doc' }, + }, + ]); + + // Noise: outside user's docs should NOT appear + await seedDraftDocument(outsideUser, outsideTeam.id, [], { + createDocumentOptions: { title: 'Outside Noise Doc' }, + }); + + // Both owner and member should see the 3 team documents + for (const user of [owner, member]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 3); + await expect(page.getByRole('link', { name: 'Team Pending Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team Draft Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team Completed Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Outside Noise Doc', exact: true }), + ).not.toBeVisible(); + + await apiSignout({ page }); + } + }); + + test('should NOT show documents from other teams', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER }); + + // Multiple docs per team to ensure isolation is thorough + await seedDocuments([ + { + sender: ownerA, + teamId: teamA.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team A Draft' }, + }, + { + sender: ownerA, + teamId: teamA.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team A Completed' }, + }, + { + sender: ownerB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team B Draft' }, + }, + { + sender: ownerB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team B Completed' }, + }, + ]); + + await apiSignin({ + page, + email: memberA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 2); + await expect(page.getByRole('link', { name: 'Team A Draft' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team A Completed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team B Draft', exact: true })).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'Team B Completed', exact: true }), + ).not.toBeVisible(); + }); + + test('should NOT show personal documents in team context', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + // Get member's personal team (seedUser creates an org with ownerUserId set) + const memberOrg = await prisma.organisation.findFirstOrThrow({ + where: { + ownerUserId: member.id, + }, + include: { + teams: true, + }, + }); + + const personalTeamId = memberOrg.teams[0].id; + + await seedDraftDocument(member, personalTeamId, [], { + createDocumentOptions: { title: 'Personal Doc not in Team' }, + }); + + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Member' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await expect(page.getByRole('link', { name: 'Team Doc by Member' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Personal Doc not in Team', exact: true }), + ).not.toBeVisible(); + }); + + test('should enforce ADMIN visibility correctly across roles', async ({ page }) => { + const { user: owner, organisation, team } = await seedUser(); + + const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [ + { organisationRole: OrganisationMemberRole.ADMIN }, + { organisationRole: OrganisationMemberRole.MEMBER }, + { organisationRole: OrganisationMemberRole.MEMBER }, + ], + }); + + // Make managerUser actually a MANAGER in the team + const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({ + where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER }, + include: { organisationGroup: true }, + }); + const managerOrgMember = await prisma.organisationMember.findFirstOrThrow({ + where: { organisationId: organisation.id, userId: managerUser.id }, + }); + await prisma.organisationGroupMember.create({ + data: { + id: generateDatabaseId('group_member'), + groupId: managerTeamGroup.organisationGroupId, + organisationMemberId: managerOrgMember.id, + }, + }); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Only Doc', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Plus Doc', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + // Admin sees all 3 + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + await checkDocumentTabCount(page, 'Completed', 3); + await apiSignout({ page }); + + // Manager sees 2 (Everyone + Manager+) + await apiSignin({ + page, + email: managerUser.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + await checkDocumentTabCount(page, 'Completed', 2); + await expect(page.getByRole('link', { name: 'Everyone Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Manager Plus Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Admin Only Doc', exact: true })).not.toBeVisible(); + await apiSignout({ page }); + + // Member sees 1 (Everyone only) + await apiSignin({ + page, + email: memberUser.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + await checkDocumentTabCount(page, 'Completed', 1); + await expect(page.getByRole('link', { name: 'Everyone Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Manager Plus Doc', exact: true }), + ).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Admin Only Doc', exact: true })).not.toBeVisible(); + await apiSignout({ page }); + }); + + test('document owner sees their document regardless of visibility restriction', async ({ + page, + }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + // Member creates an ADMIN-visibility document (should see as owner) + // Owner also creates an ADMIN doc (member should NOT see this one) + // Owner creates an EVERYONE doc (positive control, member should see) + await seedDocuments([ + { + sender: member, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Member Owned Admin Doc', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Owner Admin Doc Hidden', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc Control', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + await apiSignin({ + page, + email: member.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + + // Member sees: their own ADMIN doc + EVERYONE doc, but NOT owner's ADMIN doc + await checkDocumentTabCount(page, 'Completed', 2); + await expect(page.getByRole('link', { name: 'Member Owned Admin Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Everyone Doc Control' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Owner Admin Doc Hidden', exact: true }), + ).not.toBeVisible(); + + await apiSignout({ page }); + }); + + test('recipient sees document regardless of visibility restriction', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + // Owner creates ADMIN-only doc WITH member as recipient (member should see) + // Owner creates ADMIN-only doc WITHOUT member as recipient (member should NOT see) + // Owner creates EVERYONE doc (positive control) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [member], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc Member Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc No Member', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc Baseline', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + await apiSignin({ + page, + email: member.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + + // Member sees: ADMIN doc as recipient + EVERYONE doc, but NOT the ADMIN doc without them + await checkDocumentTabCount(page, 'Completed', 2); + await expect(page.getByRole('link', { name: 'Admin Doc Member Recipient' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Everyone Doc Baseline' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Admin Doc No Member', exact: true }), + ).not.toBeVisible(); + + await apiSignout({ page }); + }); +}); + +test.describe('Find Documents UI - Team with Team Email', () => { + test('should show documents sent TO team email', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-ui-email-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + const { user: externalUser2, team: externalTeam2 } = await seedUser(); + + await seedDocuments([ + { + sender: externalUser, + teamId: externalTeam.id, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Email Received Pending' }, + }, + { + sender: externalUser, + teamId: externalTeam.id, + recipients: [teamEmail], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Email Received Completed' }, + }, + ]); + + // Noise: docs between external users (should NOT appear in team context) + await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], { + createDocumentOptions: { title: 'External Noise Pending' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Should show received pending and completed docs but not noise + // Note: Received-via-email docs render as not since they belong to external teams + await checkDocumentTabCount(page, 'All', 2); + await expect(page.getByRole('cell', { name: 'Email Received Pending' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'External Noise Pending' })).not.toBeVisible(); + + await checkDocumentTabCount(page, 'Completed', 1); + await expect(page.getByRole('cell', { name: 'Email Received Completed' })).toBeVisible(); + }); + + test('should NOT show drafts sent TO team email', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-ui-draft-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // Draft to team email (should NOT appear) + await seedDraftDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Draft To Team Email' }, + }); + + // Pending to team email (positive control - SHOULD appear) + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Pending To Team Email' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Should see the pending doc but NOT the draft + // Received-via-email docs render as not + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('cell', { name: 'Pending To Team Email' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'Draft To Team Email' })).not.toBeVisible(); + }); + + test('should show inbox count for team email recipients', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-ui-inbox-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: sender1, team: sender1Team } = await seedUser(); + const { user: sender2, team: sender2Team } = await seedUser(); + + await seedDocuments([ + { + sender: sender1, + teamId: sender1Team.id, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Inbox Doc 1' }, + }, + { + sender: sender2, + teamId: sender2Team.id, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Inbox Doc 2' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Inbox', 2); + }); + + test('team without team email should show 0 inbox', async ({ page }) => { + const { team, owner } = await seedTeam(); + + // No team email set up + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + await seedPendingDocument(owner, team.id, [outsideUser], { + createDocumentOptions: { title: 'Pending Without Inbox' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Inbox', 0); + // But pending should still show + await checkDocumentTabCount(page, 'Pending', 1); + }); + + test('documents sent BY team email user should appear in team context', async ({ page }) => { + const { team, owner } = await seedTeam({ createTeamMembers: 1 }); + + const teamEmailHolder = owner; + + await seedTeamEmail({ + email: teamEmailHolder.email, + teamId: team.id, + }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // Team email holder sends multiple documents + await seedDocuments([ + { + sender: teamEmailHolder, + teamId: team.id, + recipients: [externalUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Sent by Holder Pending' }, + }, + { + sender: teamEmailHolder, + teamId: team.id, + recipients: [externalUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Sent by Holder Completed' }, + }, + ]); + + // Noise: external user's own docs (should NOT appear in team context) + await seedDraftDocument(externalUser, externalTeam.id, [], { + createDocumentOptions: { title: 'External Own Draft' }, + }); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await apiSignin({ + page, + email: member.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 2); + await expect(page.getByRole('link', { name: 'Sent by Holder Pending' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sent by Holder Completed' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'External Own Draft', exact: true }), + ).not.toBeVisible(); + }); +}); + +test.describe('Find Documents UI - Data Isolation & No Leaking', () => { + test('user cannot see another user personal documents via any status tab', async ({ page }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + // UserA has their own docs (positive control to verify the page works) + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'A Own Draft' }, + }); + await seedPendingDocument(userA, teamA.id, [userA], { + createDocumentOptions: { title: 'A Own Pending' }, + }); + await seedCompletedDocument(userA, teamA.id, [userA], { + createDocumentOptions: { title: 'A Own Completed' }, + }); + + // UserB has their own docs (noise — should NOT appear for userA) + await seedDocuments([ + { + sender: userB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'B Draft Private' }, + }, + { + sender: userB, + teamId: teamB.id, + recipients: [userB], + type: DocumentStatus.PENDING, + documentOptions: { title: 'B Pending Private' }, + }, + { + sender: userB, + teamId: teamB.id, + recipients: [userB], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'B Completed Private' }, + }, + ]); + + await apiSignin({ + page, + email: userA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + // UserA should see only their own docs + await checkDocumentTabCount(page, 'All', 3); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'Completed', 1); + + // Verify no B docs leaked + await page.getByRole('tab', { name: 'All' }).click(); + await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'B Draft Private', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'B Pending Private', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'B Completed Private', exact: true }), + ).not.toBeVisible(); + }); + + test('team member cannot see documents from another team via search', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER }); + + // TeamA has a doc with "Super Secret" in the title (positive control) + await seedDocuments([ + { + sender: ownerA, + teamId: teamA.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Super Secret TeamA Document', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + // TeamB has a doc with "Super Secret" in the title (should NOT appear) + await seedDocuments([ + { + sender: ownerB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Super Secret TeamB Document', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + await apiSignin({ + page, + email: memberA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Super Secret'); + await page.waitForURL(/query=Super/); + + // Should find the TeamA doc but NOT the TeamB doc + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Super Secret TeamA Document' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Super Secret TeamB Document', exact: true }), + ).not.toBeVisible(); + }); + + test('search by recipient name should respect team visibility', async ({ page }) => { + const { user: owner, organisation, team } = await seedUser(); + + const [adminUser, memberUser] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [ + { organisationRole: OrganisationMemberRole.ADMIN }, + { organisationRole: OrganisationMemberRole.MEMBER }, + ], + }); + + const { user: uniqueRecipient } = await seedUser({ name: 'Very Unique Recipient Name' }); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [uniqueRecipient], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc with Unique Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }, + ]); + + // Admin can find by recipient name + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + await page.getByPlaceholder('Search documents...').fill('Very Unique Recipient'); + await page.waitForURL(/query=Very/); + await checkDocumentTabCount(page, 'All', 1); + await apiSignout({ page }); + + // Member cannot find by recipient name (visibility blocks it) + await apiSignin({ + page, + email: memberUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + await page.getByPlaceholder('Search documents...').fill('Very Unique Recipient'); + await page.waitForURL(/query=Very/); + await checkDocumentTabCount(page, 'All', 0); + await apiSignout({ page }); + }); + + test('outside user does NOT see cross-team received docs in their personal context', async ({ + page, + }) => { + // The UI always uses the team code path (findTeamDocumentsFilter) which filters by teamId. + // Documents from team.id will NOT appear in outsideTeam's context. + const { team, owner } = await seedTeam(); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + const { user: otherUser } = await seedUser(); + + // Team doc sent to outside user (lives on team.id, NOT outsideTeam.id) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { + title: 'Team Doc For Outside User', + visibility: DocumentVisibility.ADMIN, + }, + }, + ]); + + // Team doc NOT sent to outside user (noise) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [otherUser], + type: DocumentStatus.PENDING, + documentOptions: { + title: 'Team Doc For Other User Only', + visibility: DocumentVisibility.ADMIN, + }, + }, + ]); + + // Outside user has their own draft (positive control) + await seedDraftDocument(outsideUser, outsideTeam.id, [], { + createDocumentOptions: { title: 'Outside Own Draft' }, + }); + + await apiSignin({ + page, + email: outsideUser.email, + redirectPath: `/t/${outsideTeam.url}/documents`, + }); + + // Only the outside user's own draft should appear (cross-team docs are not visible) + await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0 + await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links + await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Team Doc For Outside User', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true }), + ).not.toBeVisible(); + }); +}); + +test.describe('Find Documents UI - Tab Counts Consistency', () => { + test('personal context tab counts should be accurate', async ({ page }) => { + // In the UI, personal team uses findTeamDocumentsFilter (team code path). + // Only docs with teamId = ownerTeam.id are shown. + // Docs sent TO owner by other users live on the sender's team and won't appear. + // Without a teamEmail, INBOX returns 0. + const { user: owner, team: ownerTeam } = await seedUser(); + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + // Owner's own documents (all on ownerTeam) + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'My Draft 1' }, + }); + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'My Draft 2' }, + }); + await seedPendingDocument(owner, ownerTeam.id, [recipient], { + createDocumentOptions: { title: 'My Pending 1' }, + }); + await seedCompletedDocument(owner, ownerTeam.id, [recipient], { + createDocumentOptions: { title: 'My Completed 1' }, + }); + + // Documents sent TO owner (these live on senderTeam, NOT ownerTeam — won't appear) + await seedPendingDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Pending 1' }, + }); + await seedCompletedDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Completed 1' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${ownerTeam.url}/documents`, + }); + + // Only owner's own docs appear (received docs are on sender's team) + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0 + await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team) + await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed + }); + + test('team context tab counts should be accurate with mixed documents', async ({ page }) => { + const { team, owner } = await seedTeam({ createTeamMembers: 2 }); + + const member1 = ( + await prisma.organisation.findFirstOrThrow({ + where: { teams: { some: { id: team.id } } }, + include: { members: { include: { user: true } } }, + }) + ).members[1].user; + + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Draft 1' }, + }, + { + sender: member1, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Draft 2' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Team Pending 1' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Completed 1' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'All', 4); + }); + + test('team with team email tab counts should include received documents', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-count-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: external1, team: ext1Team } = await seedUser(); + const { user: external2, team: ext2Team } = await seedUser(); + + // Team's own documents + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Own Draft' }, + }); + await seedPendingDocument(owner, team.id, [external1], { + createDocumentOptions: { title: 'Own Pending' }, + }); + + // Documents sent TO team email + await seedPendingDocument(external1, ext1Team.id, [teamEmail], { + createDocumentOptions: { title: 'Received Pending via Email' }, + }); + await seedCompletedDocument(external2, ext2Team.id, [teamEmail], { + createDocumentOptions: { title: 'Received Completed via Email' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED) + await checkDocumentTabCount(page, 'Pending', 1); // Own pending + await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email + await checkDocumentTabCount(page, 'All', 4); // All of the above + }); +}); + +test.describe('Find Documents UI - Sender Filter', () => { + test('sender filter should narrow results correctly', async ({ page }) => { + const { team, owner } = await seedTeam({ createTeamMembers: 2 }); + + const org = await prisma.organisation.findFirstOrThrow({ + where: { teams: { some: { id: team.id } } }, + include: { members: { include: { user: true }, orderBy: { id: 'asc' } } }, + }); + + const member1 = org.members[1].user; + const member2 = org.members[2].user; + + const { user: outsideUser } = await seedUser(); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Owner Sent Doc' }, + }, + { + sender: member1, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Member1 Sent Doc' }, + }, + { + sender: member2, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Member2 Draft Doc' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Unfiltered: 3 docs total + await checkDocumentTabCount(page, 'All', 3); + + // Filter by member1 + await page.locator('button').filter({ hasText: 'Sender: All' }).click(); + await page.getByRole('option', { name: member1.name ?? '' }).click(); + await page.waitForURL(/senderIds/); + + // Should only show member1's doc + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible(); + }); +}); diff --git a/packages/lib/constants/document.ts b/packages/lib/constants/document.ts index 130cb865a..91443f56f 100644 --- a/packages/lib/constants/document.ts +++ b/packages/lib/constants/document.ts @@ -9,6 +9,12 @@ import { DocumentSignatureType } from '@documenso/lib/utils/teams'; export { DocumentSignatureType }; +/** + * Maximum count returned per status bucket in document stats. The server clamps + * each count to this value; the UI should display "10,000+" when it sees it. + */ +export const STATS_COUNT_CAP = 10_000; + export const DOCUMENT_STATUS: { [status in DocumentStatus]: { description: MessageDescriptor }; } = { diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 8b1eafcc7..21d2a923f 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,12 +1,15 @@ -import type { DocumentSource, Envelope, Prisma, Team, TeamEmail, User } from '@prisma/client'; +import type { DocumentSource, Envelope, Team, TeamEmail } from '@prisma/client'; +import { DocumentVisibility } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; import { EnvelopeType, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client'; +import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; -import { prisma } from '@documenso/prisma'; +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; -import { DocumentVisibility } from '../../types/document-visibility'; import { type FindResultResponse } from '../../types/search-params'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; import { getTeamById } from '../team/get-team'; @@ -29,8 +32,72 @@ export type FindDocumentsOptions = { senderIds?: number[]; query?: string; folderId?: string; + /** + * When true (default), use a windowed count that caps early for faster pagination. + * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. + */ + useWindowedCount?: boolean; }; +/** + * The number of pages ahead of the current page we'll scan for pagination. + * + * Instead of COUNT(*) over the entire result set (which must scan all qualifying rows), + * we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres + * stop early once it has enough rows. The offset ensures the count always reaches past + * the current page, and the window provides look-ahead for the pagination UI. + */ +const COUNT_WINDOW_SIZE = 100; + +/** + * Cap for the recipient search subquery. When searching by recipient email/name, + * we pre-compute matching envelope IDs up to this limit. This prevents + * pathological cases where a broad search (e.g. "gmail") matches millions of + * recipients and causes a heap scan. + */ +const RECIPIENT_SEARCH_CAP = 1000; + +// Kysely query builder type for Envelope queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EnvelopeQueryBuilder = SelectQueryBuilder; + +// Expression builder type scoped to Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; +type RecipientExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that a Recipient row exists for the given + * envelope with the given email, plus optional extra conditions. + */ +const recipientExists = ( + eb: EnvelopeExpressionBuilder, + email: string, + extra?: (qb: RecipientExpressionBuilder) => Expression, +) => { + let sub = eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', email); + + if (extra) { + sub = sub.where(extra); + } + + return eb.exists(sub.select(sql.lit(1).as('one'))); +}; + +/** + * Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email. + */ +const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => + eb.exists( + eb + .selectFrom('User') + .whereRef('User.id', '=', 'Envelope.userId') + .where('User.email', '=', email) + .select(sql.lit(1).as('one')), + ); + export const findDocuments = async ({ userId, teamId, @@ -44,584 +111,424 @@ export const findDocuments = async ({ senderIds, query = '', folderId, + useWindowedCount = true, }: FindDocumentsOptions) => { const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - email: true, - name: true, - }, + where: { id: userId }, + select: { id: true, email: true, name: true }, }); let team = null; if (teamId !== undefined) { - team = await getTeamById({ - userId, - teamId, - }); + team = await getTeamById({ userId, teamId }); } const orderByColumn = orderBy?.column ?? 'createdAt'; const orderByDirection = orderBy?.direction ?? 'desc'; - const teamMemberRole = team?.currentTeamRole ?? null; + const searchQuery = query.trim(); - const searchFilter: Prisma.EnvelopeWhereInput = { - 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 hasSearch = searchQuery.length > 0; + const searchPattern = `%${searchQuery}%`; + + // ─── Base query with common filters ────────────────────────────────── + // + // Every code path starts from this base: Envelope rows filtered by type, + // folder, period, sender, source, template, and search. + + const buildBaseQuery = () => { + let qb = kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.createdAt']); + + // Type must be DOCUMENT (enum cast requires raw sql — this is the one escape hatch) + qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)); + + // Folder filter + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + + // Period filter + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate()); + } + + // Sender filter + if (senderIds && senderIds.length > 0) { + qb = qb.where('Envelope.userId', 'in', senderIds); + } + + // Source filter (enum cast) + if (source) { + qb = qb.where('Envelope.source', '=', sql.lit(source)); + } + + // Template filter + if (templateId) { + qb = qb.where('Envelope.templateId', '=', templateId); + } + + // Search filter: title, externalId, or recipient match via capped subquery + if (hasSearch) { + qb = qb.where(({ or, eb }) => + or([ + eb('Envelope.title', 'ilike', searchPattern), + eb('Envelope.externalId', 'ilike', searchPattern), + // Capped recipient search subquery (uses trigram indexes) + eb( + 'Envelope.id', + 'in', + eb + .selectFrom('Recipient') + .select('Recipient.envelopeId') + .where(({ or: innerOr, eb: innerEb }) => + innerOr([ + innerEb('Recipient.email', 'ilike', searchPattern), + innerEb('Recipient.name', 'ilike', searchPattern), + ]), + ) + .distinct() + .limit(RECIPIENT_SEARCH_CAP), + ), + ]), + ); + } + + return qb; }; - const visibilityFilters = [ - match(teamMemberRole) - .with(TeamMemberRole.ADMIN, () => ({ - visibility: { - in: [ - DocumentVisibility.EVERYONE, - DocumentVisibility.MANAGER_AND_ABOVE, - DocumentVisibility.ADMIN, - ], - }, - })) - .with(TeamMemberRole.MANAGER, () => ({ - visibility: { - in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE], - }, - })) - .otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })), - { - OR: [ - { - recipients: { - some: { - email: user.email, - }, - }, - }, - { - userId: user.id, - }, - ], - }, - ]; + // ─── Personal path filters ─────────────────────────────────────────── - let filters: Prisma.EnvelopeWhereInput | null = findDocumentsFilter(status, user, folderId); + const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder | null => { + // Deleted filter: owned → deletedAt IS NULL, received → documentDeletedAt IS NULL + const personalDeletedFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]), + recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)), + ]); - if (team) { - filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId); - } + return match(status) + .with(ExtendedDocumentStatus.ALL, () => + qb.where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([ + eb('Envelope.userId', '=', user.id), + eb.and([ + eb('Envelope.status', 'in', [ + sql.lit(DocumentStatus.COMPLETED), + sql.lit(DocumentStatus.PENDING), + ]), + recipientExists(eb, user.email), + ]), + ]), + ]), + ), + ) + .with(ExtendedDocumentStatus.INBOX, () => + qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => + // Single EXISTS check: the recipient must be NOT_SIGNED, non-CC, and + // not soft-deleted. This replaces the previous personalDeletedFilter + + // separate recipientExists pair, eliminating a hashed SubPlan that + // materialised all recipient rows for this email (~125k for heavy users). + recipientExists(eb, user.email, (reb) => + reb.and([ + reb('Recipient.documentDeletedAt', 'is', null), + reb('signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)), + reb('role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ), + ) + .with(ExtendedDocumentStatus.DRAFT, () => + qb + .where('Envelope.userId', '=', user.id) + .where('Envelope.deletedAt', 'is', null) + .where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)), + ) + .with(ExtendedDocumentStatus.PENDING, () => + qb + .where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)) + .where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([ + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email, (reb) => + reb.and([ + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ]), + ]), + ), + ) + .with(ExtendedDocumentStatus.COMPLETED, () => + qb + .where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)) + .where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]), + ]), + ), + ) + .with(ExtendedDocumentStatus.REJECTED, () => + qb + .where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)) + .where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([ + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email, (reb) => + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ]), + ]), + ), + ) + .exhaustive(); + }; - if (filters === null) { + // ─── Team path filters ─────────────────────────────────────────────── + + const applyTeamFilters = ( + qb: EnvelopeQueryBuilder, + teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole }, + ): EnvelopeQueryBuilder | null => { + const teamEmail = teamData.teamEmail?.email ?? null; + + const allowedVisibilities = match(teamData.currentTeamRole) + .with(TeamMemberRole.ADMIN, () => [ + DocumentVisibility.EVERYONE, + DocumentVisibility.MANAGER_AND_ABOVE, + DocumentVisibility.ADMIN, + ]) + .with(TeamMemberRole.MANAGER, () => [ + DocumentVisibility.EVERYONE, + DocumentVisibility.MANAGER_AND_ABOVE, + ]) + .otherwise(() => [DocumentVisibility.EVERYONE]); + + // Visibility: meets role threshold OR directly involved + const visibilityFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((v) => sql.lit(v)), + ), + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email), + ]); + + // Deleted filter for team path + const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => { + const branches = [ + eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)]), + ]; + + if (teamEmail) { + branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)])); + branches.push( + recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)), + ); + } + + return eb.or(branches); + }; + + return match(status) + .with(ExtendedDocumentStatus.ALL, () => + qb.where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + eb.and([ + eb('status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)), + recipientExists(eb, teamEmail), + ]), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.INBOX, () => { + if (!teamEmail) { + return null; + } + + return qb + .where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)) + .where((eb) => + eb.and([ + visibilityFilter(eb), + // Single EXISTS check: the team-email recipient must be NOT_SIGNED, + // non-CC, and not soft-deleted. Replaces teamDeletedFilter + separate + // recipientExists, eliminating a hashed SubPlan (~79k rows). + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.documentDeletedAt', 'is', null), + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ]), + ); + }) + .with(ExtendedDocumentStatus.DRAFT, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.PENDING, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.PENDING)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.COMPLETED, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push(recipientExists(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.REJECTED, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .exhaustive(); + }; + + // ─── Assemble and execute ──────────────────────────────────────────── + + const baseQuery = buildBaseQuery(); + + const filteredQuery = team ? applyTeamFilters(baseQuery, team) : applyPersonalFilters(baseQuery); + + if (filteredQuery === null) { return { data: [], count: 0, - currentPage: 1, + currentPage: Math.max(page, 1), perPage, totalPages: 0, }; } - let deletedFilter: Prisma.EnvelopeWhereInput = { - AND: { - OR: [ - { - userId: user.id, - deletedAt: null, - }, - { - recipients: { - some: { - email: user.email, - documentDeletedAt: null, - }, - }, - }, - ], - }, - }; + const offset = Math.max(page - 1, 0) * perPage; - if (team) { - deletedFilter = { - AND: { - OR: team.teamEmail - ? [ - { - teamId: team.id, - deletedAt: null, - }, - { - user: { - email: team.teamEmail.email, - }, - deletedAt: null, - }, - { - recipients: { - some: { - email: team.teamEmail.email, - documentDeletedAt: null, - }, - }, - }, - ] - : [ - { - teamId: team.id, - deletedAt: null, - }, - ], - }, - }; - } + // Data query: paginated, executed directly via Kysely query builder + const dataQuery = filteredQuery + .orderBy(`Envelope.${orderByColumn}`, orderByDirection) + .limit(perPage) + .offset(offset); - const whereAndClause: Prisma.EnvelopeWhereInput['AND'] = [ - { ...filters }, - { ...deletedFilter }, - { ...searchFilter }, - ]; + // Count query: either windowed (fast, capped) or full (exact, for API consumers). + const baseCountQuery = filteredQuery.clearSelect().select('Envelope.id'); - if (templateId) { - whereAndClause.push({ - templateId, - }); - } + const countQuery = useWindowedCount + ? kyselyPrisma.$kysely + .selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed')) + .select(({ fn }) => fn.count('id').as('total')) + : kyselyPrisma.$kysely + .selectFrom(baseCountQuery.as('filtered')) + .select(({ fn }) => fn.count('id').as('total')); - if (source) { - whereAndClause.push({ - source, - }); - } - - const whereClause: Prisma.EnvelopeWhereInput = { - type: EnvelopeType.DOCUMENT, - AND: whereAndClause, - }; - - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); - - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); - - whereClause.createdAt = { - gte: startOfPeriod.toJSDate(), - }; - } - - if (senderIds && senderIds.length > 0) { - whereClause.userId = { - in: senderIds, - }; - } - - 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: { - user: { - select: { - id: true, - name: true, - email: true, - }, - }, - recipients: true, - team: { - select: { - id: true, - url: true, - }, - }, - envelopeItems: { - select: { - id: true, - envelopeId: true, - title: true, - order: true, - }, - }, - }, - }), - prisma.envelope.count({ - where: whereClause, - }), + const [dataResult, countResult] = await Promise.all([ + dataQuery.execute(), + countQuery.executeTakeFirstOrThrow(), ]); - const maskedData = data.map((document) => - maskRecipientTokensForDocument({ - document, - user, - }), - ); + const ids = dataResult.map((row) => row.id); + + const totalCount = useWindowedCount + ? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage) + : Number(countResult.total ?? 0); + + // ─── Hydrate with Prisma ───────────────────────────────────────────── + + if (ids.length === 0) { + return { + data: [], + count: totalCount, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(totalCount / perPage), + }; + } + + const data = await prisma.envelope.findMany({ + where: { id: { in: ids } }, + orderBy: { [orderByColumn]: orderByDirection }, + include: { + user: { select: { id: true, name: true, email: true } }, + recipients: true, + team: { select: { id: true, url: true } }, + envelopeItems: { + select: { id: true, envelopeId: true, title: true, order: true }, + }, + }, + }); + + // Preserve ordering from the Kysely query + const idOrder = new Map(ids.map((id, index) => [id, index])); + data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0)); + + const maskedData = data.map((document) => maskRecipientTokensForDocument({ document, user })); return { data: maskedData, - count, + count: totalCount, currentPage: Math.max(page, 1), perPage, - totalPages: Math.ceil(count / perPage), + totalPages: Math.ceil(totalCount / perPage), } satisfies FindResultResponse; }; - -const findDocumentsFilter = ( - status: ExtendedDocumentStatus, - user: Pick, - folderId?: string | null, -) => { - return match(status) - .with(ExtendedDocumentStatus.ALL, () => ({ - OR: [ - { - userId: user.id, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: user.email, - }, - }, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - }, - }, - folderId: folderId, - }, - ], - })) - .with(ExtendedDocumentStatus.INBOX, () => ({ - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.NOT_SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - })) - .with(ExtendedDocumentStatus.DRAFT, () => ({ - userId: user.id, - status: ExtendedDocumentStatus.DRAFT, - })) - .with(ExtendedDocumentStatus.PENDING, () => ({ - OR: [ - { - userId: user.id, - status: ExtendedDocumentStatus.PENDING, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - folderId: folderId, - }, - ], - })) - .with(ExtendedDocumentStatus.COMPLETED, () => ({ - OR: [ - { - userId: user.id, - status: ExtendedDocumentStatus.COMPLETED, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: user.email, - }, - }, - folderId: folderId, - }, - ], - })) - .with(ExtendedDocumentStatus.REJECTED, () => ({ - OR: [ - { - userId: user.id, - status: ExtendedDocumentStatus.REJECTED, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.REJECTED, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.REJECTED, - }, - }, - folderId: folderId, - }, - ], - })) - .exhaustive(); -}; - -/** - * Create a Prisma filter for the Document schema to find documents for a team. - * - * Status All: - * - Documents that belong to the team - * - Documents that have been sent by the team email - * - Non draft documents that have been sent to the team email - * - * Status Inbox: - * - Non draft documents that have been sent to the team email that have not been signed - * - * Status Draft: - * - Documents that belong to the team that are draft - * - Documents that belong to the team email that are draft - * - * Status Pending: - * - Documents that belong to the team that are pending - * - Documents that have been sent by the team email that is pending to be signed by someone else - * - Documents that have been sent to the team email that is pending to be signed by someone else - * - * Status Completed: - * - Documents that belong to the team that are completed - * - Documents that have been sent to the team email that are completed - * - Documents that have been sent by the team email that are completed - * - * @param status The status of the documents to find. - * @param team The team to find the documents for. - * @returns A filter which can be applied to the Prisma Document schema. - */ -const findTeamDocumentsFilter = ( - status: ExtendedDocumentStatus, - team: Team & { teamEmail: TeamEmail | null }, - visibilityFilters: Prisma.EnvelopeWhereInput[], - folderId?: string, -) => { - const teamEmail = team.teamEmail?.email ?? null; - - return match(status) - .with(ExtendedDocumentStatus.ALL, () => { - const filter: Prisma.EnvelopeWhereInput = { - // Filter to display all documents that belong to the team. - OR: [ - { - teamId: team.id, - folderId: folderId, - OR: visibilityFilters, - }, - ], - }; - - if (teamEmail && filter.OR) { - // Filter to display all documents received by the team email that are not draft. - filter.OR.push({ - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - recipients: { - some: { - email: teamEmail, - }, - }, - OR: visibilityFilters, - folderId: folderId, - }); - - // Filter to display all documents that have been sent by the team email. - filter.OR.push({ - user: { - email: teamEmail, - }, - OR: visibilityFilters, - folderId: folderId, - }); - } - - return filter; - }) - .with(ExtendedDocumentStatus.INBOX, () => { - // Return a filter that will return nothing. - if (!teamEmail) { - return null; - } - - return { - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.NOT_SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - OR: visibilityFilters, - folderId: folderId, - }; - }) - .with(ExtendedDocumentStatus.DRAFT, () => { - const filter: Prisma.EnvelopeWhereInput = { - OR: [ - { - teamId: team.id, - status: ExtendedDocumentStatus.DRAFT, - OR: visibilityFilters, - folderId: folderId, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push({ - status: ExtendedDocumentStatus.DRAFT, - user: { - email: teamEmail, - }, - OR: visibilityFilters, - folderId: folderId, - }); - } - - return filter; - }) - .with(ExtendedDocumentStatus.PENDING, () => { - const filter: Prisma.EnvelopeWhereInput = { - OR: [ - { - teamId: team.id, - status: ExtendedDocumentStatus.PENDING, - OR: visibilityFilters, - folderId: folderId, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push({ - status: ExtendedDocumentStatus.PENDING, - OR: [ - { - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - OR: visibilityFilters, - folderId: folderId, - }, - { - user: { - email: teamEmail, - }, - OR: visibilityFilters, - folderId: folderId, - }, - ], - }); - } - - return filter; - }) - .with(ExtendedDocumentStatus.COMPLETED, () => { - const filter: Prisma.EnvelopeWhereInput = { - status: ExtendedDocumentStatus.COMPLETED, - OR: [ - { - teamId: team.id, - OR: visibilityFilters, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push( - { - recipients: { - some: { - email: teamEmail, - }, - }, - OR: visibilityFilters, - }, - { - user: { - email: teamEmail, - }, - OR: visibilityFilters, - }, - ); - } - - return filter; - }) - .with(ExtendedDocumentStatus.REJECTED, () => { - const filter: Prisma.EnvelopeWhereInput = { - status: ExtendedDocumentStatus.REJECTED, - OR: [ - { - teamId: team.id, - OR: visibilityFilters, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push( - { - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.REJECTED, - }, - }, - OR: visibilityFilters, - }, - { - user: { - email: teamEmail, - }, - OR: visibilityFilters, - }, - ); - } - - return filter; - }) - .exhaustive(); -}; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 4d47b4e48..4f8af0a7f 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,368 +1,307 @@ -import type { Prisma, User } from '@prisma/client'; -import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client'; +import { + DocumentStatus, + EnvelopeType, + RecipientRole, + SigningStatus, + TeamMemberRole, +} from '@prisma/client'; +import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely'; import { DateTime } from 'luxon'; -import { match } from 'ts-pattern'; import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; -import { prisma } from '@documenso/prisma'; -import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { STATS_COUNT_CAP } from '../../constants/document'; +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { getTeamById } from '../team/get-team'; + +// Kysely query builder type for Envelope queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EnvelopeQueryBuilder = SelectQueryBuilder; + +// Expression builder type scoped to Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; +type RecipientExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that a Recipient row exists for the given + * envelope with the given email, plus optional extra conditions. + */ +const recipientExists = ( + eb: EnvelopeExpressionBuilder, + email: string, + extra?: (qb: RecipientExpressionBuilder) => Expression, +) => { + let sub = eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', email); + + if (extra) { + sub = sub.where(extra); + } + + return eb.exists(sub.select(sql.lit(1).as('one'))); +}; + +/** + * Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email. + */ +const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => + eb.exists( + eb + .selectFrom('User') + .whereRef('User.id', '=', 'Envelope.userId') + .where('User.email', '=', email) + .select(sql.lit(1).as('one')), + ); + export type GetStatsInput = { - user: Pick; - team?: Omit; + userId: number; + teamId: number; period?: PeriodSelectorValue; search?: string; folderId?: string; + senderIds?: number[]; +}; + +/** + * Builds a capped count from a query builder: wraps it as + * `SELECT COUNT(*) FROM (SELECT id FROM ... LIMIT cap+1) sub` + * and clamps the result to STATS_COUNT_CAP. + */ +const cappedCount = async (qb: EnvelopeQueryBuilder): Promise => { + const result = await kyselyPrisma.$kysely + .selectFrom( + qb + .clearSelect() + .select('Envelope.id') + .limit(STATS_COUNT_CAP + 1) + .as('capped'), + ) + .select(({ fn }) => fn.count('id').as('total')) + .executeTakeFirstOrThrow(); + + return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP); }; export const getStats = async ({ - user, + userId, + teamId, period, search = '', folderId, - ...options + senderIds, }: GetStatsInput) => { - let createdAt: Prisma.EnvelopeWhereInput['createdAt']; + const user = await prisma.user.findFirstOrThrow({ + where: { id: userId }, + select: { id: true, email: true }, + }); - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const team = await getTeamById({ userId, teamId }); - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + const teamEmail = team.teamEmail?.email ?? null; + const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER; + const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole]; - createdAt = { - gte: startOfPeriod.toJSDate(), - }; - } + const searchQuery = search.trim(); + const hasSearch = searchQuery.length > 0; + const searchPattern = `%${searchQuery}%`; - const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team - ? getTeamCounts({ - ...options.team, - createdAt, - currentUserEmail: user.email, - userId: user.id, - search, - folderId, - }) - : getCounts({ user, createdAt, search, folderId })); + // ─── Base query builder ────────────────────────────────────────────── - const stats: Record = { - [ExtendedDocumentStatus.DRAFT]: 0, - [ExtendedDocumentStatus.PENDING]: 0, - [ExtendedDocumentStatus.COMPLETED]: 0, - [ExtendedDocumentStatus.REJECTED]: 0, - [ExtendedDocumentStatus.INBOX]: 0, - [ExtendedDocumentStatus.ALL]: 0, + const buildBaseQuery = (): EnvelopeQueryBuilder => { + let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely + .selectFrom('Envelope') + .select('Envelope.id'); + + // Type = DOCUMENT + qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)); + + // Folder filter + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + + // Period filter + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate()); + } + + // Sender filter + if (senderIds && senderIds.length > 0) { + qb = qb.where('Envelope.userId', 'in', senderIds); + } + + // Search filter + if (hasSearch) { + qb = qb.where(({ or, eb }) => + or([ + eb('Envelope.title', 'ilike', searchPattern), + eb('Envelope.externalId', 'ilike', searchPattern), + eb( + 'Envelope.id', + 'in', + eb + .selectFrom('Recipient') + .select('Recipient.envelopeId') + .where(({ or: innerOr, eb: innerEb }) => + innerOr([ + innerEb('Recipient.email', 'ilike', searchPattern), + innerEb('Recipient.name', 'ilike', searchPattern), + ]), + ) + .distinct() + .limit(1000), + ), + ]), + ); + } + + return qb; }; - ownerCounts.forEach((stat) => { - stats[stat.status] = stat._count._all; - }); + // ─── Shared filter helpers ─────────────────────────────────────────── - notSignedCounts.forEach((stat) => { - stats[ExtendedDocumentStatus.INBOX] += stat._count._all; - }); + const visibilityFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((v) => sql.lit(v)), + ), + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email), + ]); - hasSignedCounts.forEach((stat) => { - if (stat.status === ExtendedDocumentStatus.COMPLETED) { - stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; + const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => { + const branches = [ + eb.and([eb('Envelope.teamId', '=', team.id), eb('Envelope.deletedAt', 'is', null)]), + ]; + + if (teamEmail) { + branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)])); + branches.push( + recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)), + ); } - if (stat.status === ExtendedDocumentStatus.PENDING) { - stats[ExtendedDocumentStatus.PENDING] += stat._count._all; - } + return eb.or(branches); + }; - if (stat.status === ExtendedDocumentStatus.REJECTED) { - stats[ExtendedDocumentStatus.REJECTED] += stat._count._all; - } - }); + // ─── Per-status query builders ─────────────────────────────────────── - Object.keys(stats).forEach((key) => { - if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { - stats[ExtendedDocumentStatus.ALL] += stats[key]; - } - }); + // DRAFT: team-owned drafts visible to the user + const draftQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // PENDING: team-owned pending + team-email signed-pending docs + const pendingQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // COMPLETED: team-owned completed + team-email received completed + const completedQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push(recipientExists(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // REJECTED: team-owned rejected + team-email rejected docs + const rejectedQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient + // Returns 0 if the team has no team email. + const inboxQuery = teamEmail + ? buildBaseQuery() + .where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)) + .where((eb) => + eb.and([ + visibilityFilter(eb), + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.documentDeletedAt', 'is', null), + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ]), + ) + : null; + + // ─── Execute all counts in parallel ────────────────────────────────── + + const [draft, pending, completed, rejected, inbox] = await Promise.all([ + cappedCount(draftQuery), + cappedCount(pendingQuery), + cappedCount(completedQuery), + cappedCount(rejectedQuery), + inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0), + ]); + + const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP); + + const stats: Record = { + [ExtendedDocumentStatus.DRAFT]: draft, + [ExtendedDocumentStatus.PENDING]: pending, + [ExtendedDocumentStatus.COMPLETED]: completed, + [ExtendedDocumentStatus.REJECTED]: rejected, + [ExtendedDocumentStatus.INBOX]: inbox, + [ExtendedDocumentStatus.ALL]: all, + }; return stats; }; - -type GetCountsOption = { - user: Pick; - createdAt: Prisma.EnvelopeWhereInput['createdAt']; - search?: string; - folderId?: string | null; -}; - -const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => { - const searchFilter: Prisma.EnvelopeWhereInput = { - OR: [ - { title: { contains: search, mode: 'insensitive' } }, - { recipients: { some: { name: { contains: search, mode: 'insensitive' } } } }, - { recipients: { some: { email: { contains: search, mode: 'insensitive' } } } }, - ], - }; - - const rootPageFilter = folderId === undefined ? { folderId: null } : {}; - - return Promise.all([ - // Owner counts. - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - userId: user.id, - createdAt, - deletedAt: null, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - }), - // Not signed counts. - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.NOT_SIGNED, - documentDeletedAt: null, - }, - }, - createdAt, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - }), - // Has signed counts. - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - createdAt, - user: { - email: { - not: user.email, - }, - }, - OR: [ - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - }, - ], - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - }), - ]); -}; - -type GetTeamCountsOption = { - teamId: number; - teamEmail?: string; - senderIds?: number[]; - currentUserEmail: string; - userId: number; - createdAt: Prisma.EnvelopeWhereInput['createdAt']; - currentTeamMemberRole?: TeamMemberRole; - search?: string; - folderId?: string | null; -}; - -const getTeamCounts = async (options: GetTeamCountsOption) => { - const { createdAt, teamId, teamEmail, folderId } = options; - - const senderIds = options.senderIds ?? []; - - const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] = - senderIds.length > 0 - ? { - in: senderIds, - } - : undefined; - - const searchFilter: Prisma.EnvelopeWhereInput = { - OR: [ - { title: { contains: options.search, mode: 'insensitive' } }, - { recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } }, - { recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } }, - ], - }; - - const rootPageFilter = folderId === undefined ? { folderId: null } : {}; - - let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - teamId, - deletedAt: null, - }; - - let notSignedCountsGroupByArgs = null; - let hasSignedCountsGroupByArgs = null; - - const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = { - AND: [ - { deletedAt: null }, - { - OR: [ - match(options.currentTeamMemberRole) - .with(TeamMemberRole.ADMIN, () => ({ - visibility: { - in: [ - DocumentVisibility.EVERYONE, - DocumentVisibility.MANAGER_AND_ABOVE, - DocumentVisibility.ADMIN, - ], - }, - })) - .with(TeamMemberRole.MANAGER, () => ({ - visibility: { - in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE], - }, - })) - .otherwise(() => ({ - visibility: { - equals: DocumentVisibility.EVERYONE, - }, - })), - { - OR: [ - { userId: options.userId }, - { recipients: { some: { email: options.currentUserEmail } } }, - ], - }, - ], - }, - ], - }; - - ownerCountsWhereInput = { - ...ownerCountsWhereInput, - AND: [ - ...(Array.isArray(visibilityFiltersWhereInput.AND) - ? visibilityFiltersWhereInput.AND - : visibilityFiltersWhereInput.AND - ? [visibilityFiltersWhereInput.AND] - : []), - searchFilter, - rootPageFilter, - folderId ? { folderId } : {}, - ], - }; - - if (teamEmail) { - ownerCountsWhereInput = { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - OR: [ - { - teamId, - }, - { - user: { - email: teamEmail, - }, - }, - ], - deletedAt: null, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }; - - notSignedCountsGroupByArgs = { - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.NOT_SIGNED, - documentDeletedAt: null, - }, - }, - deletedAt: null, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - } satisfies Prisma.EnvelopeGroupByArgs; - - hasSignedCountsGroupByArgs = { - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - OR: [ - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - }, - ], - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - } satisfies Prisma.EnvelopeGroupByArgs; - } - - return Promise.all([ - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: ownerCountsWhereInput, - }), - notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [], - hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [], - ]); -}; diff --git a/packages/lib/server-only/envelope/find-envelopes.ts b/packages/lib/server-only/envelope/find-envelopes.ts index 03906d816..2abd97ef8 100644 --- a/packages/lib/server-only/envelope/find-envelopes.ts +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -1,12 +1,8 @@ -import type { - DocumentSource, - DocumentStatus, - Envelope, - EnvelopeType, - Prisma, -} from '@prisma/client'; +import type { DocumentSource, DocumentStatus, Envelope, EnvelopeType } from '@prisma/client'; +import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely'; -import { prisma } from '@documenso/prisma'; +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { FindResultResponse } from '../../types/search-params'; @@ -28,8 +24,77 @@ export type FindEnvelopesOptions = { }; query?: string; folderId?: string; + /** + * When true (default), use a windowed count that caps early for faster pagination. + * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. + */ + useWindowedCount?: boolean; }; +/** + * The number of pages ahead of the current page we'll scan for pagination. + * + * Instead of COUNT(*) over the entire result set (which must scan all qualifying rows), + * we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres + * stop early once it has enough rows. + */ +const COUNT_WINDOW_SIZE = 100; + +/** + * Cap for the recipient search subquery. When searching by recipient email/name, + * we pre-compute matching envelope IDs up to this limit to prevent pathological + * heap scans on broad searches. + */ +const RECIPIENT_SEARCH_CAP = 1000; + +// Kysely query builder type for Envelope queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EnvelopeQueryBuilder = SelectQueryBuilder; + +// Expression builder type scoped to Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; +type RecipientExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that a Recipient row exists for the given + * envelope with the given email, plus optional extra conditions. + */ +const recipientExists = ( + eb: EnvelopeExpressionBuilder, + email: string, + extra?: (qb: RecipientExpressionBuilder) => Expression, +) => { + let sub = eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', email); + + if (extra) { + sub = sub.where(extra); + } + + return eb.exists(sub.select(sql.lit(1).as('one'))); +}; + +/** + * Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email. + */ +const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => + eb.exists( + eb + .selectFrom('User') + .whereRef('User.id', '=', 'Envelope.userId') + .where('User.email', '=', email) + .select(sql.lit(1).as('one')), + ); + +/** + * Find envelopes visible to the requesting user within a team. + * + * Unlike `findDocuments` (used by the UI), being a recipient does NOT override + * document visibility. A user will only see an envelope if its visibility level + * is within their role's threshold, or they are the document owner. + */ export const findEnvelopes = async ({ userId, teamId, @@ -42,134 +107,175 @@ export const findEnvelopes = async ({ orderBy, query = '', folderId, + useWindowedCount = true, }: FindEnvelopesOptions) => { const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - email: true, - name: true, - }, + where: { id: userId }, + select: { id: true, email: true, name: true }, }); - const team = await getTeamById({ - userId, - teamId, - }); + const team = await getTeamById({ userId, teamId }); const orderByColumn = orderBy?.column ?? 'createdAt'; const orderByDirection = orderBy?.direction ?? 'desc'; + const searchQuery = query.trim(); + const hasSearch = searchQuery.length > 0; + const searchPattern = `%${searchQuery}%`; - 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 teamEmail = team.teamEmail?.email ?? null; + const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole]; - const visibilityFilter: Prisma.EnvelopeWhereInput = { - visibility: { - in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], - }, - }; + // ─── Build Kysely query ────────────────────────────────────────────── - const teamEmailFilters: Prisma.EnvelopeWhereInput[] = []; + let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.createdAt']); - if (team.teamEmail) { - teamEmailFilters.push( - { - user: { - email: team.teamEmail.email, - }, - }, - { - recipients: { - some: { - email: team.teamEmail.email, - }, - }, - }, + // Folder filter + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + + // Exclude soft-deleted envelopes + qb = qb.where('Envelope.deletedAt', 'is', null); + + // Type filter (enum cast) + if (type) { + qb = qb.where('Envelope.type', '=', sql.lit(type)); + } + + // Template filter + if (templateId) { + qb = qb.where('Envelope.templateId', '=', templateId); + } + + // Source filter (enum cast) + if (source) { + qb = qb.where('Envelope.source', '=', sql.lit(source)); + } + + // Status filter (enum cast) + if (status) { + qb = qb.where('Envelope.status', '=', sql.lit(status)); + } + + // Search filter: title, externalId, or recipient match via capped subquery + if (hasSearch) { + qb = qb.where(({ or, eb }) => + or([ + eb('Envelope.title', 'ilike', searchPattern), + eb('Envelope.externalId', 'ilike', searchPattern), + eb( + 'Envelope.id', + 'in', + eb + .selectFrom('Recipient') + .select('Recipient.envelopeId') + .where(({ or: innerOr, eb: innerEb }) => + innerOr([ + innerEb('Recipient.email', 'ilike', searchPattern), + innerEb('Recipient.name', 'ilike', searchPattern), + ]), + ) + .distinct() + .limit(RECIPIENT_SEARCH_CAP), + ), + ]), ); } - const whereClause: Prisma.EnvelopeWhereInput = { - AND: [ - { - OR: [ - { - teamId: team.id, - ...visibilityFilter, - }, - { - userId, - }, - ...teamEmailFilters, - ], - }, - { - folderId: folderId ?? null, - deletedAt: null, - }, - searchFilter, - ], - }; + // ─── Access control ────────────────────────────────────────────────── + // + // An envelope is visible if ANY of: + // 1. It belongs to this team AND (meets the visibility threshold OR the requesting user is the owner) + // 2. (If team email) The sender's email matches the team email + // 3. (If team email) A recipient's email matches the team email - if (type) { - whereClause.type = type; - } + const visibilityFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((v) => sql.lit(v)), + ), + // Owner always sees their own docs within this team + eb('Envelope.userId', '=', user.id), + ]); - if (templateId) { - whereClause.templateId = templateId; - } + qb = qb.where((eb) => { + const accessBranches: Expression[] = [ + // Team docs that pass visibility (or are owned by the user) + eb.and([eb('Envelope.teamId', '=', team.id), visibilityFilter(eb)]), + ]; - if (source) { - whereClause.source = source; - } + if (teamEmail) { + // Docs sent by the team email user + accessBranches.push(senderEmailIs(eb, teamEmail)); + // Docs received by the team email + accessBranches.push(recipientExists(eb, teamEmail)); + } - if (status) { - whereClause.status = status; - } + return eb.or(accessBranches); + }); - 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, - }), + // ─── Execute: paginated data + count ────────────────────────────────── + + const offset = Math.max(page - 1, 0) * perPage; + + const dataQuery = qb + .orderBy(`Envelope.${orderByColumn}`, orderByDirection) + .limit(perPage) + .offset(offset); + + // Count query: either windowed (fast, capped) or full (exact, for API consumers). + const baseCountQuery = qb.clearSelect().select('Envelope.id'); + + const countQuery = useWindowedCount + ? kyselyPrisma.$kysely + .selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed')) + .select(({ fn }) => fn.count('id').as('total')) + : kyselyPrisma.$kysely + .selectFrom(baseCountQuery.as('filtered')) + .select(({ fn }) => fn.count('id').as('total')); + + const [dataResult, countResult] = await Promise.all([ + dataQuery.execute(), + countQuery.executeTakeFirstOrThrow(), ]); + const ids = dataResult.map((row) => row.id); + + const totalCount = useWindowedCount + ? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage) + : Number(countResult.total ?? 0); + + // ─── Hydrate with Prisma ───────────────────────────────────────────── + + if (ids.length === 0) { + return { + data: [], + count: totalCount, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(totalCount / perPage), + } satisfies FindResultResponse; + } + + const data = await prisma.envelope.findMany({ + where: { id: { in: ids } }, + orderBy: { [orderByColumn]: orderByDirection }, + include: { + user: { select: { id: true, name: true, email: true } }, + recipients: { orderBy: { id: 'asc' } }, + team: { select: { id: true, url: true } }, + }, + }); + + // Preserve ordering from the Kysely query + const idOrder = new Map(ids.map((id, index) => [id, index])); + data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0)); + const maskedData = data.map((envelope) => maskRecipientTokensForDocument({ document: envelope, @@ -189,9 +295,9 @@ export const findEnvelopes = async ({ return { data: mappedData, - count, + count: totalCount, currentPage: Math.max(page, 1), perPage, - totalPages: Math.ceil(count / perPage), + totalPages: Math.ceil(totalCount / perPage), } satisfies FindResultResponse; }; diff --git a/packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql b/packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql new file mode 100644 index 000000000..8331a85cb --- /dev/null +++ b/packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql @@ -0,0 +1,17 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_documentDeletedAt_envelopeId_idx" ON "Recipient"("email", "documentDeletedAt", "envelopeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_envelopeId_idx" ON "Recipient"("email", "envelopeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_signingStatus_envelopeId_role_idx" ON "Recipient"("email", "signingStatus", "envelopeId", "role"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_trgm_idx" ON "Recipient" USING GIN ("email" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_name_trgm_idx" ON "Recipient" USING GIN ("name" gin_trgm_ops); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 467be8d2e..d41931a5c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -598,6 +598,11 @@ model Recipient { @@index([envelopeId]) @@index([signedAt]) @@index([expiresAt]) + @@index([email, documentDeletedAt, envelopeId], map: "Recipient_email_documentDeletedAt_envelopeId_idx") + @@index([email, envelopeId], map: "Recipient_email_envelopeId_idx") + @@index([email, signingStatus, envelopeId, role], map: "Recipient_email_signingStatus_envelopeId_role_idx") + @@index([email(ops: raw("gin_trgm_ops"))], map: "Recipient_email_trgm_idx", type: Gin) + @@index([name(ops: raw("gin_trgm_ops"))], map: "Recipient_name_trgm_idx", type: Gin) } enum FieldType { diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts index 2dfb38254..ff31b882d 100644 --- a/packages/trpc/server/document-router/find-documents-internal.ts +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -1,7 +1,5 @@ import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; -import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; -import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document'; import { authenticatedProcedure } from '../trpc'; @@ -30,28 +28,15 @@ export const findDocumentsInternalRoute = authenticatedProcedure folderId, } = input; - const getStatOptions: GetStatsInput = { - user, - period, - search: query, - folderId, - }; - - if (teamId) { - const team = await getTeamById({ userId: user.id, teamId }); - - getStatOptions.team = { - teamId: team.id, - teamEmail: team.teamEmail?.email, - senderIds, - currentTeamMemberRole: team.currentTeamRole, - currentUserEmail: user.email, - userId: user.id, - }; - } - const [stats, documents] = await Promise.all([ - getStats(getStatOptions), + getStats({ + userId: user.id, + teamId, + period, + search: query, + folderId, + senderIds, + }), findDocuments({ userId: user.id, teamId, diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index d7b0be598..b7a829b21 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -38,6 +38,7 @@ export const findDocumentsRoute = authenticatedProcedure perPage, folderId, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + useWindowedCount: false, }); return { diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts index a16cf46f9..26e6755a1 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -52,5 +52,6 @@ export const findEnvelopesRoute = authenticatedProcedure perPage, folderId, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + useWindowedCount: false, }); });