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,
});
});