From e128c3152b9e997a0ed2e3331c567af79f16af99 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 14:21:21 +1000 Subject: [PATCH] feat: allow admin recipient search --- .../general/use-admin-search-categories.ts | 3 +- .../admin+/documents._index.tsx | 2 +- .../app-tests/e2e/admin/global-search.spec.ts | 47 ++++ .../trpc/admin/admin-find-documents.spec.ts | 226 ++++++++++++++++++ .../server-only/admin/admin-find-documents.ts | 46 +++- 5 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 packages/app-tests/e2e/api/trpc/admin/admin-find-documents.spec.ts diff --git a/apps/remix/app/components/general/use-admin-search-categories.ts b/apps/remix/app/components/general/use-admin-search-categories.ts index fc950b708..547fe7afb 100644 --- a/apps/remix/app/components/general/use-admin-search-categories.ts +++ b/apps/remix/app/components/general/use-admin-search-categories.ts @@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record = { /** * Admin list pages which support prefilling their search from the URL, used - * for the "View all results" links on capped groups. Teams, recipients and + * for the "View all results" links on capped groups. Teams and * subscriptions have no admin list pages. */ const ADMIN_GROUP_LIST_PATHS: Partial string>> = { document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`, user: (query) => `/admin/users?search=${encodeURIComponent(query)}`, organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`, + recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`, }; export type UseAdminSearchCategoriesOptions = { diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx index 1f1bb5317..b9757b3af 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx @@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
setTerm(e.target.value)} /> diff --git a/packages/app-tests/e2e/admin/global-search.spec.ts b/packages/app-tests/e2e/admin/global-search.spec.ts index d8a1b5fbe..3687114f7 100644 --- a/packages/app-tests/e2e/admin/global-search.spec.ts +++ b/packages/app-tests/e2e/admin/global-search.spec.ts @@ -437,3 +437,50 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e expect(adminSearchRequests).toHaveLength(0); }); + +test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const recipientPrefix = `viewall-recipient-${nanoid()}`; + + // Seed 5 documents, each with one recipient email sharing the prefix, to + // hit the 5 result cap on the recipients group. + const documents = []; + + for (let i = 0; i < 5; i++) { + documents.push( + await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], { + createDocumentOptions: { title: `recipient-viewall-${nanoid()}` }, + }), + ); + } + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix); + + await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible(); + + // Only the recipients group matches the prefix, so this is its link. + const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first(); + + await expect(viewAllOption.getByRole('link')).toHaveAttribute( + 'href', + `/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`, + ); + + await viewAllOption.click(); + + await page.waitForURL((url) => url.pathname === '/admin/documents'); + + // The term input is prefilled with the recipient query and the matching + // documents are listed. + await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`); + + for (const document of documents) { + await expect(page.getByRole('link', { name: document.title })).toBeVisible(); + } +}); diff --git a/packages/app-tests/e2e/api/trpc/admin/admin-find-documents.spec.ts b/packages/app-tests/e2e/api/trpc/admin/admin-find-documents.spec.ts new file mode 100644 index 000000000..92ea6b4cc --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/admin/admin-find-documents.spec.ts @@ -0,0 +1,226 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin } from '../../../fixtures/authentication'; + +const nanoid = customAlphabet('1234567890abcdef', 10); + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +type AdminFindDocumentsResult = { + data: Array<{ envelopeId: string; title: string }>; + count: number; +}; + +const callAdminFindDocuments = async (page: Page, query: string) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: { query, page: 1, perPage: 20 } })); + const url = `${WEBAPP_BASE_URL}/api/trpc/admin.document.find?input=${inputParam}`; + + const res = await page.context().request.get(url); + + return { + res, + result: res.ok() + ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + ((await res.json()).result.data.json as AdminFindDocumentsResult) + : null, + }; +}; + +// ─── Access control ────────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: non-admin user is rejected with 401', async ({ page }) => { + const { user: nonAdminUser } = await seedUser({ isAdmin: false }); + + await apiSignin({ page, email: nonAdminUser.email }); + + const { res } = await callAdminFindDocuments(page, 'recipient:anything'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); +}); + +// ─── recipient: prefix ─────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient email', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const recipientEmail = `recipient-find-${nanoid()}@test.documenso.com`; + + const matchingDocument = await seedPendingDocument(sender, team.id, [recipientEmail], { + createDocumentOptions: { title: `recipient-find-match-${nanoid()}` }, + }); + + const otherDocument = await seedPendingDocument(sender, team.id, [`other-${nanoid()}@test.documenso.com`], { + createDocumentOptions: { title: `recipient-find-other-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientEmail}`); + + expect(res.ok()).toBeTruthy(); + expect(result?.count).toBe(1); + expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id); + expect(result?.data.map((document) => document.envelopeId)).not.toContain(otherDocument.id); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient name case-insensitively', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const recipientName = `recipient-name-${nanoid()}`; + const { user: recipientUser } = await seedUser({ name: recipientName }); + + const matchingDocument = await seedPendingDocument(sender, team.id, [recipientUser], { + createDocumentOptions: { title: `recipient-name-match-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + // Query the uppercased name: matching must be case-insensitive. + const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientName.toUpperCase()}`); + + expect(res.ok()).toBeTruthy(); + expect(result?.count).toBe(1); + expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with empty value returns no results', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const emptyValueSearch = await callAdminFindDocuments(page, 'recipient:'); + + expect(emptyValueSearch.res.ok()).toBeTruthy(); + expect(emptyValueSearch.result?.data).toEqual([]); + expect(emptyValueSearch.result?.count).toBe(0); + + // Whitespace-only values are treated the same as empty. + const whitespaceValueSearch = await callAdminFindDocuments(page, 'recipient: '); + + expect(whitespaceValueSearch.res.ok()).toBeTruthy(); + expect(whitespaceValueSearch.result?.data).toEqual([]); + expect(whitespaceValueSearch.result?.count).toBe(0); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with no matches returns no results', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, result } = await callAdminFindDocuments(page, 'recipient:zzzz-no-such-recipient-9x7q'); + + expect(res.ok()).toBeTruthy(); + expect(result?.data).toEqual([]); + expect(result?.count).toBe(0); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with numeric value matches by exact recipient ID', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const matchingDocument = await seedPendingDocument(sender, team.id, [`recipient-id-${nanoid()}@test.documenso.com`], { + createDocumentOptions: { title: `recipient-id-match-${nanoid()}` }, + }); + + const recipient = matchingDocument.recipients[0]; + + // A decoy whose recipient email contains the ID as text: an exact ID lookup + // must exclude it, while an accidental "ID or contains" match would not. + const decoyDocument = await seedPendingDocument( + sender, + team.id, + [`decoy-${recipient.id}-${nanoid()}@test.documenso.com`], + { + createDocumentOptions: { title: `recipient-id-decoy-${nanoid()}` }, + }, + ); + + await apiSignin({ page, email: adminUser.email }); + + const { res, result } = await callAdminFindDocuments(page, `recipient:${recipient.id}`); + + expect(res.ok()).toBeTruthy(); + expect(result?.count).toBe(1); + expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id); + expect(result?.data.map((document) => document.envelopeId)).not.toContain(decoyDocument.id); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with nonexistent recipient ID returns no results', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + // Int4 max - 1: a valid ID-shaped number that no autoincrement recipient + // sequence will plausibly reach, and that no other test seeds as text. + const { res, result } = await callAdminFindDocuments(page, 'recipient:2147483646'); + + expect(res.ok()).toBeTruthy(); + expect(result?.data).toEqual([]); + expect(result?.count).toBe(0); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with oversized number falls back to text search', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + // 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be + // treated as text (and must not 500). + const oversizedNumber = '99999999999999'; + + const matchingDocument = await seedPendingDocument( + sender, + team.id, + [`${oversizedNumber}-${nanoid()}@test.documenso.com`], + { + createDocumentOptions: { title: `recipient-oversized-${nanoid()}` }, + }, + ); + + await apiSignin({ page, email: adminUser.email }); + + const { res, result } = await callAdminFindDocuments(page, `recipient:${oversizedNumber}`); + + expect(res.ok()).toBeTruthy(); + expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id); +}); + +test('[ADMIN][TRPC][FIND_DOCUMENTS]: user and team prefixes with oversized numbers return no results', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + // 99999999999999 exceeds Int4, so it can never be a valid user or team ID. + // The ID schema must reject it so the query returns empty instead of + // overflowing Postgres and erroring. + const userSearch = await callAdminFindDocuments(page, 'user:99999999999999'); + + expect(userSearch.res.ok()).toBeTruthy(); + expect(userSearch.result?.data).toEqual([]); + expect(userSearch.result?.count).toBe(0); + + const teamSearch = await callAdminFindDocuments(page, 'team:99999999999999'); + + expect(teamSearch.res.ok()).toBeTruthy(); + expect(teamSearch.result?.data).toEqual([]); + expect(teamSearch.result?.count).toBe(0); +}); diff --git a/packages/lib/server-only/admin/admin-find-documents.ts b/packages/lib/server-only/admin/admin-find-documents.ts index 994422ba7..fdd7502cb 100644 --- a/packages/lib/server-only/admin/admin-find-documents.ts +++ b/packages/lib/server-only/admin/admin-find-documents.ts @@ -10,7 +10,13 @@ export interface AdminFindDocumentsOptions { perPage?: number; } -const ZPositiveIntegerSchema = z.coerce.number().int().positive(); +const MAX_POSTGRES_INT = 2147483647; + +/** + * IDs are Postgres int4 columns: values above the range can never be valid + * IDs and would make Prisma throw on overflow, so the schema rejects them. + */ +const ZPositiveIntegerSchema = z.coerce.number().int().positive().max(MAX_POSTGRES_INT); const emptyResponse = { data: [], @@ -58,7 +64,41 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi } } - if (query && query?.startsWith('envelope_')) { + if (query?.startsWith('recipient:')) { + const recipientQuery = query.slice('recipient:'.length).trim(); + + if (recipientQuery.length === 0) { + return emptyResponse; + } + + // Bare numeric values are exact recipient ID lookups, consistent with the + // user: and team: prefixes. Oversized numbers cannot be IDs and fall back + // to the text search, mirroring the admin global search. + const parsedRecipientId = ZPositiveIntegerSchema.safeParse(recipientQuery); + + if (parsedRecipientId.success) { + termFilters = { + recipients: { + some: { + id: parsedRecipientId.data, + }, + }, + }; + } else { + termFilters = { + recipients: { + some: { + OR: [ + { email: { contains: recipientQuery, mode: 'insensitive' } }, + { name: { contains: recipientQuery, mode: 'insensitive' } }, + ], + }, + }, + }; + } + } + + if (query?.startsWith('envelope_')) { termFilters = { id: { equals: query, @@ -66,7 +106,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi }; } - if (query && query?.startsWith('document_')) { + if (query?.startsWith('document_')) { termFilters = { secondaryId: { equals: query,