diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx index 08bc44ec5..787aa58d7 100644 --- a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx @@ -16,6 +16,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { DocumentStatus } from '~/components/formatter/document-status'; @@ -25,16 +26,17 @@ import { DataTableSenderFilter } from './data-table-sender-filter'; import { EmptyDocumentState } from './empty-state'; import { UploadDocument } from './upload-document'; -export type DocumentsPageViewProps = { +export interface DocumentsPageViewProps { searchParams?: { status?: ExtendedDocumentStatus; period?: PeriodSelectorValue; page?: string; perPage?: string; senderIds?: string; + search?: string; }; team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } }; -}; +} export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => { const { user } = await getRequiredServerComponentSession(); @@ -44,6 +46,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); + const search = searchParams.search || ''; const currentTeam = team ? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email } : undefined; @@ -52,6 +55,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa const getStatOptions: GetStatsInput = { user, period, + search, }; if (team) { @@ -79,6 +83,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa perPage, period, senderIds, + search, }); const getTabHref = (value: typeof status) => { @@ -148,6 +153,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
+
+ +
diff --git a/apps/web/src/components/(dashboard)/document-search/document-search.tsx b/apps/web/src/components/(dashboard)/document-search/document-search.tsx new file mode 100644 index 000000000..dbfad6775 --- /dev/null +++ b/apps/web/src/components/(dashboard)/document-search/document-search.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { useRouter, useSearchParams } from 'next/navigation'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { Input } from '@documenso/ui/primitives/input'; + +export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const [searchTerm, setSearchTerm] = useState(initialValue); + const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); + + const handleSearch = useCallback( + (term: string) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + if (term) { + params.set('search', term); + } else { + params.delete('search'); + } + router.push(`?${params.toString()}`); + }, + [router, searchParams], + ); + + useEffect(() => { + handleSearch(searchTerm); + }, [debouncedSearchTerm]); + + return ( + setSearchTerm(e.target.value)} + /> + ); +}; diff --git a/packages/app-tests/e2e/teams/search-documents.spec.ts b/packages/app-tests/e2e/teams/search-documents.spec.ts new file mode 100644 index 000000000..b56e4fd00 --- /dev/null +++ b/packages/app-tests/e2e/teams/search-documents.spec.ts @@ -0,0 +1,249 @@ +import { expect, test } from '@playwright/test'; + +import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client'; +import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; +import { seedTeam, 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('[TEAMS]: search respects team document visibility', async ({ page }) => { + const team = await seedTeam(); + const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await seedDocuments([ + { + sender: team.owner, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + visibility: 'EVERYONE', + title: 'Searchable Document for Everyone', + }, + }, + { + sender: team.owner, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + visibility: 'MANAGER_AND_ABOVE', + title: 'Searchable Document for Managers', + }, + }, + { + sender: team.owner, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + visibility: 'ADMIN', + title: 'Searchable Document for Admins', + }, + }, + ]); + + const testCases = [ + { user: adminUser, visibleDocs: 3 }, + { user: managerUser, visibleDocs: 2 }, + { user: memberUser, visibleDocs: 1 }, + ]; + + for (const { user, visibleDocs } of testCases) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Searchable'); + await page.waitForURL(/search=Searchable/); + + await checkDocumentTabCount(page, 'All', visibleDocs); + + await apiSignout({ page }); + } +}); + +test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => { + const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments(); + const { team: teamB } = await seedTeamDocuments(); + + await seedDocuments([ + { + sender: teamA.owner, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: teamA.id, + visibility: 'EVERYONE', + title: 'Unique Team A Document', + }, + }, + { + sender: teamB.owner, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: teamB.id, + visibility: 'EVERYONE', + title: 'Unique Team B Document', + }, + }, + ]); + + await apiSignin({ + page, + email: teamAMember.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Unique'); + await page.waitForURL(/search=Unique/); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Unique Team A Document' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Unique Team B Document' })).not.toBeVisible(); + + await apiSignout({ page }); +}); + +test('[PERSONAL]: search does not reveal team documents in personal account', async ({ page }) => { + const { team, teamMember2 } = await seedTeamDocuments(); + + await seedDocuments([ + { + sender: teamMember2, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: null, + title: 'Personal Unique Document', + }, + }, + { + sender: team.owner, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + visibility: 'EVERYONE', + title: 'Team Unique Document', + }, + }, + ]); + + await apiSignin({ + page, + email: teamMember2.email, + redirectPath: '/documents', + }); + + await page.getByPlaceholder('Search documents...').fill('Unique'); + await page.waitForURL(/search=Unique/); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team Unique Document' })).not.toBeVisible(); + + await apiSignout({ page }); +}); + +test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({ + page, +}) => { + const team = await seedTeam(); + const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await seedDocuments([ + { + sender: team.owner, + recipients: [memberUser], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + visibility: 'ADMIN', + title: 'Admin Document with Member Recipient', + }, + }, + ]); + + await apiSignin({ + page, + email: memberUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Admin Document'); + await page.waitForURL(/search=Admin(%20|\+|\s)Document/); + + await checkDocumentTabCount(page, 'All', 1); + await expect( + page.getByRole('link', { name: 'Admin Document with Member Recipient' }), + ).toBeVisible(); + + await apiSignout({ page }); +}); + +test('[TEAMS]: search by recipient name respects visibility', async ({ page }) => { + const team = await seedTeam(); + const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const memberUser = await seedTeamMember({ + teamId: team.id, + role: TeamMemberRole.MEMBER, + name: 'Team Member', + }); + + const uniqueRecipient = await seedUser(); + + await seedDocuments([ + { + sender: team.owner, + recipients: [uniqueRecipient], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + visibility: 'ADMIN', + title: 'Admin Document for Unique Recipient', + }, + }, + ]); + + // Admin should see the document when searching by recipient name + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Unique Recipient'); + await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/); + + await checkDocumentTabCount(page, 'All', 1); + await expect( + page.getByRole('link', { name: 'Admin Document for Unique Recipient' }), + ).toBeVisible(); + + await apiSignout({ page }); + + // Member should not see the document when searching by recipient name + await apiSignin({ + page, + email: memberUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Unique Recipient'); + await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/); + + await checkDocumentTabCount(page, 'All', 0); + await expect( + page.getByRole('link', { name: 'Admin Document for Unique Recipient' }), + ).not.toBeVisible(); + + await apiSignout({ page }); +}); diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 03aeacc86..2495973f2 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -25,6 +25,7 @@ export type FindDocumentsOptions = { }; period?: PeriodSelectorValue; senderIds?: number[]; + search?: string; }; export const findDocuments = async ({ @@ -37,6 +38,7 @@ export const findDocuments = async ({ orderBy, period, senderIds, + search, }: FindDocumentsOptions) => { const { user, team } = await prisma.$transaction(async (tx) => { const user = await tx.user.findFirstOrThrow({ @@ -92,6 +94,14 @@ export const findDocuments = async ({ }) .otherwise(() => undefined); + const searchFilter: Prisma.DocumentWhereInput = { + OR: [ + { title: { contains: search, mode: 'insensitive' } }, + { Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } }, + { Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } }, + ], + }; + const visibilityFilters = [ match(teamMemberRole) .with(TeamMemberRole.ADMIN, () => ({ @@ -188,7 +198,7 @@ export const findDocuments = async ({ } const whereClause: Prisma.DocumentWhereInput = { - AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }], + AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }, { ...searchFilter }], }; if (period) { diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 60c3dcea8..9ea58c828 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -15,9 +15,10 @@ export type GetStatsInput = { user: User; team?: Omit; period?: PeriodSelectorValue; + search?: string; }; -export const getStats = async ({ user, period, ...options }: GetStatsInput) => { +export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => { let createdAt: Prisma.DocumentWhereInput['createdAt']; if (period) { @@ -31,8 +32,14 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => { } const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team - ? getTeamCounts({ ...options.team, createdAt, currentUserEmail: user.email, userId: user.id }) - : getCounts({ user, createdAt })); + ? getTeamCounts({ + ...options.team, + createdAt, + currentUserEmail: user.email, + userId: user.id, + search, + }) + : getCounts({ user, createdAt, search })); const stats: Record = { [ExtendedDocumentStatus.DRAFT]: 0, @@ -72,9 +79,18 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => { type GetCountsOption = { user: User; createdAt: Prisma.DocumentWhereInput['createdAt']; + search?: string; }; -const getCounts = async ({ user, createdAt }: GetCountsOption) => { +const getCounts = async ({ user, createdAt, search }: GetCountsOption) => { + const searchFilter: Prisma.DocumentWhereInput = { + OR: [ + { title: { contains: search, mode: 'insensitive' } }, + { Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } }, + { Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } }, + ], + }; + return Promise.all([ // Owner counts. prisma.document.groupBy({ @@ -87,6 +103,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { createdAt, teamId: null, deletedAt: null, + AND: [searchFilter], }, }), // Not signed counts. @@ -105,6 +122,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { }, }, createdAt, + AND: [searchFilter], }, }), // Has signed counts. @@ -142,6 +160,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { }, }, ], + AND: [searchFilter], }, }), ]); @@ -155,6 +174,7 @@ type GetTeamCountsOption = { userId: number; createdAt: Prisma.DocumentWhereInput['createdAt']; currentTeamMemberRole?: TeamMemberRole; + search?: string; }; const getTeamCounts = async (options: GetTeamCountsOption) => { @@ -169,6 +189,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { } : undefined; + const searchFilter: Prisma.DocumentWhereInput = { + OR: [ + { title: { contains: options.search, mode: 'insensitive' } }, + { Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } }, + { Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } }, + ], + }; + let ownerCountsWhereInput: Prisma.DocumentWhereInput = { userId: userIdWhereClause, createdAt, @@ -220,6 +248,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, }, ], + ...searchFilter, }; if (teamEmail) { diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index aaa9fe2f7..1822b62d6 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -1316,7 +1316,7 @@ msgstr "Dokument wird dauerhaft gelöscht" #: apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx:109 #: apps/web/src/app/(dashboard)/documents/[id]/loading.tsx:16 #: apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx:15 -#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:114 +#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:119 #: apps/web/src/app/(profile)/p/[url]/page.tsx:166 #: apps/web/src/app/not-found.tsx:21 #: apps/web/src/components/(dashboard)/common/command-menu.tsx:205 diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po index 21bc4f1a1..be7276ed4 100644 --- a/packages/lib/translations/en/web.po +++ b/packages/lib/translations/en/web.po @@ -1311,7 +1311,7 @@ msgstr "Document will be permanently deleted" #: apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx:109 #: apps/web/src/app/(dashboard)/documents/[id]/loading.tsx:16 #: apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx:15 -#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:114 +#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:119 #: apps/web/src/app/(profile)/p/[url]/page.tsx:166 #: apps/web/src/app/not-found.tsx:21 #: apps/web/src/components/(dashboard)/common/command-menu.tsx:205 diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index 5b3108a09..9fac1a10d 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -1316,7 +1316,7 @@ msgstr "Le document sera supprimé de manière permanente" #: apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx:109 #: apps/web/src/app/(dashboard)/documents/[id]/loading.tsx:16 #: apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx:15 -#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:114 +#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:119 #: apps/web/src/app/(profile)/p/[url]/page.tsx:166 #: apps/web/src/app/not-found.tsx:21 #: apps/web/src/components/(dashboard)/common/command-menu.tsx:205 diff --git a/packages/prisma/seed/teams.ts b/packages/prisma/seed/teams.ts index 65cf3b600..28e1eb390 100644 --- a/packages/prisma/seed/teams.ts +++ b/packages/prisma/seed/teams.ts @@ -105,13 +105,15 @@ export const unseedTeam = async (teamUrl: string) => { type SeedTeamMemberOptions = { teamId: number; role?: TeamMemberRole; + name?: string; }; export const seedTeamMember = async ({ teamId, + name, role = TeamMemberRole.ADMIN, }: SeedTeamMemberOptions) => { - const user = await seedUser(); + const user = await seedUser({ name }); await prisma.teamMember.create({ data: { diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 8583473bb..73f1cb157 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -21,8 +21,13 @@ export const seedUser = async ({ password = 'password', verified = true, }: SeedUserOptions = {}) => { - if (!name) { + let url = name; + + if (name) { + url = nanoid(); + } else { name = nanoid(); + url = name; } if (!email) { @@ -35,7 +40,7 @@ export const seedUser = async ({ email, password: hashSync(password), emailVerified: verified ? new Date() : undefined, - url: name, + url, }, }); };