diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx index 17e8c9740..b2e8c8efb 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx @@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({ if (isDocument) { await trpcUtils.document.findDocumentsInternal.invalidate(); } else { - await trpcUtils.template.findTemplates.invalidate(); + await Promise.all([ + trpcUtils.template.findTemplates.invalidate(), + trpcUtils.template.findTemplatesInternal.invalidate(), + ]); } if (result.failedIds.length > 0) { diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx index 69dbb8793..4ced9cbc8 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({ if (isDocument) { await trpcUtils.document.findDocumentsInternal.invalidate(); } else { - await trpcUtils.template.findTemplates.invalidate(); + await Promise.all([ + trpcUtils.template.findTemplates.invalidate(), + trpcUtils.template.findTemplatesInternal.invalidate(), + ]); } await onSuccess?.(data.folderId); diff --git a/apps/remix/app/components/general/document/document-search.tsx b/apps/remix/app/components/general/document/document-search.tsx index bb0819008..84a5c8012 100644 --- a/apps/remix/app/components/general/document/document-search.tsx +++ b/apps/remix/app/components/general/document/document-search.tsx @@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce import { Input } from '@documenso/ui/primitives/input'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { useQueryState } from 'nuqs'; +import { useQueryStates } from 'nuqs'; import { useEffect, useState } from 'react'; import { documentsSearchParams } from '~/utils/documents-search-params'; @@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params'; export const DocumentSearch = () => { const { _ } = useLingui(); - const [query, setQuery] = useQueryState('query', documentsSearchParams.query); + const [{ query }, setSearchParams] = useQueryStates( + { + query: documentsSearchParams.query, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); const [searchTerm, setSearchTerm] = useState(query ?? ''); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); useEffect(() => { if (debouncedSearchTerm !== (query ?? '')) { - void setQuery(debouncedSearchTerm || null); + // Reset pagination so a new search never lands on an empty page. + void setSearchParams({ + query: debouncedSearchTerm || null, + page: null, + }); } - }, [debouncedSearchTerm, query, setQuery]); + }, [debouncedSearchTerm, query, setSearchParams]); return ( { + const { _ } = useLingui(); + + const [{ query }, setSearchParams] = useQueryStates( + { + query: templatesSearchParams.query, + page: templatesSearchParams.page, + }, + { history: 'push' }, + ); + + const [searchTerm, setSearchTerm] = useState(query ?? ''); + const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); + + useEffect(() => { + if (debouncedSearchTerm !== (query ?? '')) { + void setSearchParams({ + query: debouncedSearchTerm || null, + page: null, + }); + } + }, [debouncedSearchTerm, query, setSearchParams]); + + return ( + setSearchTerm(e.target.value)} + data-testid="templates-search-input" + /> + ); +}; diff --git a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx index 9d8a288f5..f2b512b97 100644 --- a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -170,7 +170,10 @@ export const TemplatesTableActionDropdown = ({ onOpenChange={setRenameDialogOpen} envelopeType="template" onSuccess={async () => { - await trpcUtils.template.findTemplates.invalidate(); + await Promise.all([ + trpcUtils.template.findTemplates.invalidate(), + trpcUtils.template.findTemplatesInternal.invalidate(), + ]); }} /> diff --git a/apps/remix/app/components/tables/templates-table-owner-filter.tsx b/apps/remix/app/components/tables/templates-table-owner-filter.tsx new file mode 100644 index 000000000..39fef4193 --- /dev/null +++ b/apps/remix/app/components/tables/templates-table-owner-filter.tsx @@ -0,0 +1,61 @@ +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { trpc } from '@documenso/trpc/react'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { UserIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { templatesSearchParams } from '~/utils/templates-search-params'; + +type TemplatesTableOwnerFilterProps = { + teamId: number; +}; + +export const TemplatesTableOwnerFilter = ({ teamId }: TemplatesTableOwnerFilterProps) => { + const { _ } = useLingui(); + + const isMounted = useIsMounted(); + + const [{ ownerIds }, setSearchParams] = useQueryStates( + { + ownerIds: templatesSearchParams.ownerIds, + page: templatesSearchParams.page, + }, + { history: 'push' }, + ); + + const selectedOwnerIds = (ownerIds ?? []).map((ownerId) => ownerId.toString()); + + const { data, isLoading } = trpc.team.member.getMany.useQuery({ + teamId, + }); + + const options = (data ?? []).map((member) => ({ + label: member.name ?? member.email, + value: member.userId.toString(), + })); + + const onChange = (newOwnerIds: string[]) => { + void setSearchParams({ + ownerIds: newOwnerIds.length > 0 ? newOwnerIds.map(Number) : null, + page: null, + }); + }; + + return ( + Owner} + value={selectedOwnerIds} + onChange={onChange} + options={options} + enableSearch + searchPlaceholder={_(msg`Search members...`)} + loading={!isMounted || isLoading} + testId="templates-table-owner-filter" + /> + ); +}; diff --git a/apps/remix/app/components/tables/templates-table-view-filter.tsx b/apps/remix/app/components/tables/templates-table-view-filter.tsx new file mode 100644 index 000000000..b30cf8e64 --- /dev/null +++ b/apps/remix/app/components/tables/templates-table-view-filter.tsx @@ -0,0 +1,42 @@ +import { Trans } from '@lingui/react/macro'; +import { Building2Icon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params'; + +const VIEW_OPTIONS = [ + { value: 'team', label: Team }, + { value: 'organisation', label: Organisation }, +]; + +export const TemplatesTableViewFilter = () => { + const [{ view }, setSearchParams] = useQueryStates( + { + view: templatesSearchParams.view, + ownerIds: templatesSearchParams.ownerIds, + page: templatesSearchParams.page, + }, + { history: 'push' }, + ); + + const onChange = (newView: string | null) => { + // The owner filter only applies to the team view, so drop it on any view change. + void setSearchParams({ + view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null, + ownerIds: null, + page: null, + }); + }; + + return ( + View} + value={view} + onChange={onChange} + options={VIEW_OPTIONS} + testId="templates-table-view-filter" + /> + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index 81070e43e..f4d61a894 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -5,26 +5,28 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType, OrganisationType } from '@prisma/client'; -import { Bird } from 'lucide-react'; -import { parseAsStringLiteral, useQueryState } from 'nuqs'; +import { Bird, XIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; import { useMemo, useState } from 'react'; -import { useParams, useSearchParams } from 'react-router'; +import { useParams } from 'react-router'; import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; +import { TemplateSearch } from '~/components/general/template/template-search'; import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { TemplatesTable } from '~/components/tables/templates-table'; +import { TemplatesTableOwnerFilter } from '~/components/tables/templates-table-owner-filter'; +import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; - -const TEMPLATE_VIEWS = ['team', 'organisation'] as const; +import { templatesSearchParams } from '~/utils/templates-search-params'; export function meta() { return appMetaTags(msg`Templates`); @@ -39,15 +41,26 @@ export default function TemplatesPage() { const organisation = useCurrentOrganisation(); const { folderId } = useParams(); - const [searchParams] = useSearchParams(); + const [findTemplateSearchParams, setFindTemplateSearchParams] = useQueryStates(templatesSearchParams, { + history: 'push', + }); + const page = findTemplateSearchParams.page || undefined; + const perPage = findTemplateSearchParams.perPage || undefined; + const query = findTemplateSearchParams.query || undefined; + const ownerIds = findTemplateSearchParams.ownerIds ?? undefined; - const page = Number(searchParams.get('page')) || 1; - const perPage = Number(searchParams.get('perPage')) || 10; + const isOrgView = findTemplateSearchParams.view === 'organisation'; + const showOrgFilter = organisation.type !== OrganisationType.PERSONAL; - const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team')); + const hasActiveFilters = Boolean(ownerIds?.length); + const isSearchingOrFiltering = hasActiveFilters || Boolean(query); - const isOrgView = view === 'organisation'; - const showOrgTab = organisation.type !== OrganisationType.PERSONAL; + const onResetFilters = () => { + void setFindTemplateSearchParams({ + ownerIds: null, + page: null, + }); + }; // Scoped by team so selections made in one team never leak into another. const [rowSelection, setRowSelection] = useSessionStorage( @@ -64,11 +77,13 @@ export default function TemplatesPage() { const documentRootPath = formatDocumentsPath(team.url); const templateRootPath = formatTemplatesPath(team.url); - const teamTemplatesQuery = trpc.template.findTemplates.useQuery( + const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery( { page, perPage, folderId, + query, + ownerIds, }, { enabled: !isOrgView, @@ -79,6 +94,7 @@ export default function TemplatesPage() { { page, perPage, + query, }, { enabled: isOrgView, @@ -87,14 +103,6 @@ export default function TemplatesPage() { const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery; - const handleViewChange = (newView: string) => { - if (newView !== 'team' && newView !== 'organisation') { - return; - } - - void setView(newView === 'team' ? null : newView); - }; - return (
@@ -112,31 +120,25 @@ export default function TemplatesPage() {
- {showOrgTab && ( -
- - - - Team - - - Organisation - - - +
+
+
- )} + + {showOrgFilter && } + + {!isOrgView && } + + {hasActiveFilters && ( + + )} +
- {activeQuery.data && activeQuery.data.count === 0 ? ( + {activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
diff --git a/apps/remix/app/utils/templates-search-params.ts b/apps/remix/app/utils/templates-search-params.ts new file mode 100644 index 000000000..b25ee305e --- /dev/null +++ b/apps/remix/app/utils/templates-search-params.ts @@ -0,0 +1,17 @@ +import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs'; + +export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const; + +/** + * Shared nuqs parsers for the templates page URL state. + * + * Used by the templates page and its filter components so every consumer + * parses and serialises the params identically. + */ +export const templatesSearchParams = { + view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES), + ownerIds: parseAsArrayOf(parseAsInteger), + page: parseAsInteger, + perPage: parseAsInteger, + query: parseAsString, +}; diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts index 143c6225b..c768a23a5 100644 --- a/packages/app-tests/e2e/documents/find-documents.spec.ts +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -167,6 +167,29 @@ test.describe('Find Documents UI - Personal Context', () => { await expect(page.getByRole('link', { name: 'Annual Budget Plan', exact: true })).not.toBeVisible(); }); + test('should reset pagination when the search query changes', 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' }, + }); + + // Start on a page that would be empty once the search narrows the results. + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents?page=2&perPage=1`, + }); + + await page.getByPlaceholder('Search documents...').fill('Quarterly'); + await page.waitForURL((url) => url.searchParams.get('query') === 'Quarterly' && !url.searchParams.has('page')); + + await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible(); + }); + test('should not show deleted documents', async ({ page }) => { const { user: owner, team } = await seedUser(); diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index abe766eff..76d7eab65 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -40,6 +40,88 @@ test('[TEMPLATES]: view templates', async ({ page }) => { await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results'); }); +test('[TEMPLATES]: search templates by title', async ({ page }) => { + const { team, owner } = await seedTeam(); + + await seedTemplate({ + title: 'Quarterly Report Template', + userId: owner.id, + teamId: team.id, + }); + + await seedTemplate({ + title: 'Annual Budget Template', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results'); + + await page.getByPlaceholder('Search templates...').fill('Quarterly'); + await page.waitForURL(/query=Quarterly/); + + await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result'); + await expect(page.getByRole('link', { name: 'Quarterly Report Template' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Annual Budget Template' })).not.toBeVisible(); + + // Clearing the search should restore the full list and drop the URL param. + await page.getByPlaceholder('Search templates...').fill(''); + await page.waitForURL((url) => !url.searchParams.has('query')); + + await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results'); +}); + +test('[TEMPLATES]: filter templates by owner', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamMemberUser = await seedTeamMember({ + teamId: team.id, + name: 'Filter Member', + role: TeamMemberRole.MEMBER, + }); + + await seedTemplate({ + title: 'Owner Template', + userId: owner.id, + teamId: team.id, + }); + + await seedTemplate({ + title: 'Member Template', + userId: teamMemberUser.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results'); + + await page.getByTestId('templates-table-owner-filter').click(); + await page.getByRole('option', { name: 'Filter Member' }).click(); + await page.waitForURL(/ownerIds=/); + await page.keyboard.press('Escape'); + + await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result'); + await expect(page.getByRole('link', { name: 'Member Template' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Owner Template' })).not.toBeVisible(); + + // Reset should clear the owner filter. + await page.getByRole('button', { name: 'Reset' }).click(); + await page.waitForURL((url) => !url.searchParams.has('ownerIds')); + + await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results'); +}); + test('[TEMPLATES]: delete template', async ({ page }) => { const { team, owner, organisation } = await seedTeam({ createTeamMembers: 1, diff --git a/packages/app-tests/e2e/templates/organisation-templates.spec.ts b/packages/app-tests/e2e/templates/organisation-templates.spec.ts index 2322cf04d..1e51ca714 100644 --- a/packages/app-tests/e2e/templates/organisation-templates.spec.ts +++ b/packages/app-tests/e2e/templates/organisation-templates.spec.ts @@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record { - test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => { +test.describe('Organisation Templates - UI View Filter', () => { + test('should show the view filter for non-personal orgs', async ({ page }) => { const { ownerA, teamA } = await seedOrgTemplateScenario(); await apiSignin({ @@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => { redirectPath: `/t/${teamA.url}/templates`, }); - await expect(page.getByTestId('template-tab-team')).toBeVisible(); - await expect(page.getByTestId('template-tab-organisation')).toBeVisible(); + await expect(page.getByTestId('templates-table-view-filter')).toBeVisible(); }); - test('should not show tabs for personal organisations', async ({ page }) => { + test('should not show the view filter for personal organisations', async ({ page }) => { const { user, team } = await seedUser({ isPersonalOrganisation: true }); await apiSignin({ @@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => { redirectPath: `/t/${team.url}/templates`, }); - await expect(page.getByTestId('template-tab-team')).not.toBeVisible(); - await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible(); + await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible(); }); }); // ─── UI: Listing Organisation Templates ────────────────────────────────────── test.describe('Organisation Templates - Listing', () => { - test('should list org templates from other teams under the Organisation tab', async ({ page }) => { + test('should list org templates from other teams under the organisation view', async ({ page }) => { const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); await apiSignin({ @@ -140,17 +138,30 @@ test.describe('Organisation Templates - Listing', () => { redirectPath: `/t/${teamB.url}/templates`, }); - // Team tab should show 0 (memberB has no templates on teamB). - await expect(page.getByTestId('template-tab-team')).toBeVisible(); + // Team view is active by default (memberB has no templates on teamB). + await expect(page.getByTestId('templates-table-view-filter')).toBeVisible(); - // Switch to Organisation tab. - await page.getByTestId('template-tab-organisation').click(); + // Switch to the organisation view. + await page.getByTestId('templates-table-view-filter').click(); + await page.getByRole('option', { name: 'Organization' }).click(); // Should see the org template from teamA. await expect(page.getByText(orgTemplate.title)).toBeVisible(); }); - test('should not show private templates from other teams under Organisation tab', async ({ page }) => { + test('should use default pagination when URL values are zero', async ({ page }) => { + const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); + + await apiSignin({ + page, + email: memberB.email, + redirectPath: `/t/${teamB.url}/templates?view=organisation&page=0&perPage=0`, + }); + + await expect(page.getByText(orgTemplate.title)).toBeVisible(); + }); + + test('should not show private templates from other teams under the organisation view', async ({ page }) => { const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); // Create a private template on teamA — should NOT appear in org tab. diff --git a/packages/app-tests/e2e/templates/test-unauthorized-find-templates-access.spec.ts b/packages/app-tests/e2e/templates/test-unauthorized-find-templates-access.spec.ts new file mode 100644 index 000000000..659fdfccc --- /dev/null +++ b/packages/app-tests/e2e/templates/test-unauthorized-find-templates-access.spec.ts @@ -0,0 +1,514 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { prisma } from '@documenso/prisma'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { APIResponse, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentVisibility, FolderType, TeamMemberRole, TemplateType } from '@prisma/client'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +const nanoid = customAlphabet('1234567890abcdef', 10); + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +type FindTemplatesResult = { + data: Array<{ title: string; userId: number; teamId: number }>; + count: number; +}; + +type TrpcResponse = { + response: APIResponse; + result: FindTemplatesResult | null; +}; + +const trpcQuery = async ( + page: Page, + procedure: string, + input: Record, + teamId?: number, +): Promise => { + const inputParam = encodeURIComponent(JSON.stringify({ json: { page: 1, perPage: 50, ...input } })); + const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`; + + const headers: Record = teamId ? { 'x-team-id': teamId.toString() } : {}; + + const response = await page.context().request.get(url, { headers }); + + const json = response.ok() ? await response.json() : null; + const result: FindTemplatesResult | null = json ? json.result.data.json : null; + + return { response, result }; +}; + +const FIND_TEMPLATE_PROCEDURES = ['template.findTemplates', 'template.findTemplatesInternal'] as const; + +const titlesOf = (res: TrpcResponse) => (res.result?.data ?? []).map((row) => row.title); + +const expectRejected = (res: TrpcResponse, status: number) => { + expect(res.response.status()).toBe(status); + expect(res.result).toBeNull(); +}; + +// Check both count and data so one cannot be wrong while the other looks fine. +const expectNoResults = (res: TrpcResponse) => { + expect(res.response.ok()).toBeTruthy(); + expect(res.result?.count).toBe(0); + expect(res.result?.data).toEqual([]); +}; + +const expectExactTitles = (res: TrpcResponse, titles: string[], teamId: number) => { + expect(res.response.ok()).toBeTruthy(); + expect(res.result?.count).toBe(titles.length); + expect(titlesOf(res).sort()).toEqual([...titles].sort()); + expect(res.result?.data.every((row) => row.teamId === teamId)).toBe(true); +}; + +/** + * Org A has two teams. teamA is the default team, so every org member is in it. + * teamB was created with inheritMembers: false, so only people added directly are in it. + * - ownerA: org owner and teamA admin. Owns all the templates below. + * - memberA, managerA: member and manager of teamA. Not in teamB. + * - memberB: member of teamB, and also a member of teamA because teamA inherits. + * - teamA has four templates with the same suffix in the title, one per visibility: + * everyone, manager, admin (with a unique externalId and recipient email), and org. + * + * Org B is a separate org owned by "outsider". No overlap with Org A. + * + * So: memberB with a teamB header checks that teamA rows never come back. + * memberA with a teamB header checks that non-members are rejected. + */ +const seedScenario = async () => { + const { user: ownerA, organisation, team: teamA } = await seedUser(); + + const teamBUrl = `team-b-${nanoid()}`; + + await createTeam({ + userId: ownerA.id, + teamName: `Team B ${teamBUrl}`, + teamUrl: teamBUrl, + organisationId: organisation.id, + inheritMembers: false, + }); + + const teamB = await prisma.team.findFirstOrThrow({ where: { url: teamBUrl } }); + + const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER }); + const managerA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MANAGER }); + const memberB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MEMBER }); + + const { user: outsider, team: outsiderTeam } = await seedUser(); + + const suffix = nanoid(); + const hiddenRecipientEmail = `hidden-recipient-${suffix}@example.com`; + + const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Everyone Template ${suffix}`, + visibility: DocumentVisibility.EVERYONE, + }, + }); + + const managerTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Manager Template ${suffix}`, + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }); + + const adminTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Admin Only Template ${suffix}`, + externalId: `admin-external-${suffix}`, + visibility: DocumentVisibility.ADMIN, + recipients: { + create: { + email: hiddenRecipientEmail, + name: `Hidden Recipient ${suffix}`, + token: nanoid(), + }, + }, + }, + }); + + const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Org Template ${suffix}`, + templateType: TemplateType.ORGANISATION, + visibility: DocumentVisibility.EVERYONE, + }, + }); + + const allTitles = [everyoneTemplate.title, orgTemplate.title, managerTemplate.title, adminTemplate.title]; + + // Which templates each role on teamA should see. + const visibilityMatrix = [ + { caller: memberA, visible: [everyoneTemplate.title, orgTemplate.title] }, + { caller: managerA, visible: [everyoneTemplate.title, orgTemplate.title, managerTemplate.title] }, + { caller: ownerA, visible: allTitles }, + ]; + + return { + ownerA, + memberA, + managerA, + memberB, + teamA, + teamB, + outsider, + outsiderTeam, + suffix, + everyoneTemplate, + managerTemplate, + adminTemplate, + orgTemplate, + hiddenRecipientEmail, + allTitles, + visibilityMatrix, + }; +}; + +// ─── Not logged in, or using a team header for a team you are not in ───────── + +test.describe('Find Templates API - Adversarial: Auth and Team Header', () => { + for (const procedure of FIND_TEMPLATE_PROCEDURES) { + test(`${procedure}: should reject unauthenticated requests`, async ({ page }) => { + const { teamA } = await seedScenario(); + + const res = await trpcQuery(page, procedure, {}, teamA.id); + + expectRejected(res, 401); + }); + + test(`${procedure}: should reject a team header for a team the user is not in`, async ({ page }) => { + const { memberA, teamA, teamB, outsider } = await seedScenario(); + + const adminA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN }); + + const cases = [ + { name: 'org member, not in team', caller: memberA, teamId: teamB.id }, + { name: 'admin of another team', caller: adminA, teamId: teamB.id }, + { name: 'other organisation', caller: outsider, teamId: teamA.id }, + { name: 'no team header', caller: memberA, teamId: undefined }, + ]; + + for (const { caller, teamId } of cases) { + await apiSignin({ page, email: caller.email }); + + const res = await trpcQuery(page, procedure, {}, teamId); + + expectRejected(res, 404); + + await apiSignout({ page }); + } + }); + } + + test('findOrganisationTemplates: should reject a team header for a team in another org', async ({ page }) => { + const { outsider, teamA } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + const res = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamA.id); + + expectRejected(res, 404); + }); +}); + +// ─── Search must not find templates you cannot see ────────────────────────── + +test.describe('Find Templates API - Adversarial: Search', () => { + for (const procedure of FIND_TEMPLATE_PROCEDURES) { + test(`${procedure}: search must not find templates hidden by role`, async ({ page }) => { + const { memberA, teamA, adminTemplate, hiddenRecipientEmail } = await seedScenario(); + + await apiSignin({ page, email: memberA.email }); + + for (const query of [adminTemplate.title, adminTemplate.externalId, hiddenRecipientEmail]) { + const res = await trpcQuery(page, procedure, { query }, teamA.id); + + expectNoResults(res); + } + }); + + test(`${procedure}: search must not find templates from another team`, async ({ page }) => { + const { memberB, teamB, everyoneTemplate } = await seedScenario(); + + await apiSignin({ page, email: memberB.email }); + + const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, teamB.id); + + expectNoResults(res); + }); + + test(`${procedure}: search must not find templates from another org`, async ({ page }) => { + const { outsider, outsiderTeam, everyoneTemplate } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, outsiderTeam.id); + + expectNoResults(res); + }); + + test(`${procedure}: list and search show only what each role is allowed to see`, async ({ page }) => { + const { teamA, suffix, allTitles, visibilityMatrix } = await seedScenario(); + + for (const { caller, visible } of visibilityMatrix) { + await apiSignin({ page, email: caller.email }); + + const listRes = await trpcQuery(page, procedure, {}, teamA.id); + + expectExactTitles(listRes, visible, teamA.id); + + const searchRes = await trpcQuery(page, procedure, { query: suffix }, teamA.id); + + expectExactTitles(searchRes, visible, teamA.id); + + for (const hidden of allTitles.filter((title) => !visible.includes(title))) { + const res = await trpcQuery(page, procedure, { query: hidden }, teamA.id); + + expectNoResults(res); + } + + await apiSignout({ page }); + } + }); + + test(`${procedure}: wildcard search must not show more than allowed`, async ({ page }) => { + const { memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario(); + + await apiSignin({ page, email: memberA.email }); + + // % and _ are not escaped, so these match everything you are allowed to see. + for (const query of ['%', '_', '%%%']) { + const res = await trpcQuery(page, procedure, { query }, teamA.id); + + expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id); + } + + for (const query of ['%Admin Only%', '%Manager%']) { + const res = await trpcQuery(page, procedure, { query }, teamA.id); + + expectNoResults(res); + } + }); + } + + test('findOrganisationTemplates: search must not find templates from another org', async ({ page }) => { + const { outsider, outsiderTeam, orgTemplate } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + const res = await trpcQuery( + page, + 'template.findOrganisationTemplates', + { query: orgTemplate.title }, + outsiderTeam.id, + ); + + expectNoResults(res); + }); + + test('findOrganisationTemplates: search must not find team templates from another team', async ({ page }) => { + const { memberB, teamB, everyoneTemplate, managerTemplate, adminTemplate } = await seedScenario(); + + await apiSignin({ page, email: memberB.email }); + + for (const { title } of [everyoneTemplate, managerTemplate, adminTemplate]) { + const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: title }, teamB.id); + + expectNoResults(res); + } + }); + + test('findOrganisationTemplates: shows only what the user role on the requesting team allows', async ({ page }) => { + const { ownerA, teamA, teamB, memberB, orgTemplate } = await seedScenario(); + + const managerOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Manager Org Template ${nanoid()}`, + templateType: TemplateType.ORGANISATION, + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }); + + const adminOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Admin Org Template ${nanoid()}`, + templateType: TemplateType.ORGANISATION, + visibility: DocumentVisibility.ADMIN, + }, + }); + + const managerB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MANAGER }); + + const allOrgTitles = [orgTemplate.title, managerOrgTemplate.title, adminOrgTemplate.title]; + + const matrix = [ + { caller: memberB, visible: [orgTemplate.title] }, + { caller: managerB, visible: [orgTemplate.title, managerOrgTemplate.title] }, + ]; + + for (const { caller, visible } of matrix) { + await apiSignin({ page, email: caller.email }); + + const listRes = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamB.id); + + expectExactTitles(listRes, visible, teamA.id); + + for (const hidden of allOrgTitles.filter((title) => !visible.includes(title))) { + const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: hidden }, teamB.id); + + expectNoResults(res); + } + + await apiSignout({ page }); + } + }); +}); + +// ─── Owner filter must not reach other teams or skip visibility checks ─────── + +test.describe('Find Templates API - Adversarial: Owner Filter', () => { + const procedure = 'template.findTemplatesInternal'; + + test('owner filter must not find templates from another team', async ({ page }) => { + const { ownerA, memberB, teamB } = await seedScenario(); + + await apiSignin({ page, email: memberB.email }); + + const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, teamB.id); + + expectNoResults(res); + }); + + test('owner filter must not find templates from another org', async ({ page }) => { + const { ownerA, outsider, outsiderTeam } = await seedScenario(); + + await apiSignin({ page, email: outsider.email }); + + const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, outsiderTeam.id); + + expectNoResults(res); + }); + + test('owning a template only makes it visible in its own team', async ({ page }) => { + const { memberB, teamA, teamB } = await seedScenario(); + + // memberB is in both teams and owns an admin-only template in each. + // They can only see these because they own them. That must not cross teams. + const ownedOnA = await seedBlankTemplate(memberB, teamA.id, { + createTemplateOptions: { title: `Owned On A ${nanoid()}`, visibility: DocumentVisibility.ADMIN }, + }); + + const ownedOnB = await seedBlankTemplate(memberB, teamB.id, { + createTemplateOptions: { title: `Owned On B ${nanoid()}`, visibility: DocumentVisibility.ADMIN }, + }); + + await apiSignin({ page, email: memberB.email }); + + const inputs = [ + {}, + { ownerIds: [memberB.id] }, + { query: 'Owned On' }, + { ownerIds: [memberB.id], query: 'Owned On' }, + ]; + + for (const input of inputs) { + // teamB has no other templates, so this is the only row. + const fromB = await trpcQuery(page, procedure, input, teamB.id); + + expectExactTitles(fromB, [ownedOnB.title], teamB.id); + + const fromA = await trpcQuery(page, procedure, input, teamA.id); + + expect(fromA.response.ok()).toBeTruthy(); + expect(titlesOf(fromA)).toContain(ownedOnA.title); + expect(titlesOf(fromA)).not.toContain(ownedOnB.title); + } + }); + + test('owner filter shows only what each role is allowed to see', async ({ page }) => { + const { ownerA, teamA, suffix, allTitles, visibilityMatrix } = await seedScenario(); + + const ownerIds = [ownerA.id]; + + for (const { caller, visible } of visibilityMatrix) { + await apiSignin({ page, email: caller.email }); + + const listRes = await trpcQuery(page, procedure, { ownerIds }, teamA.id); + + expectExactTitles(listRes, visible, teamA.id); + + const searchRes = await trpcQuery(page, procedure, { ownerIds, query: suffix }, teamA.id); + + expectExactTitles(searchRes, visible, teamA.id); + + for (const hidden of allTitles.filter((title) => !visible.includes(title))) { + const res = await trpcQuery(page, procedure, { ownerIds, query: hidden }, teamA.id); + + expectNoResults(res); + } + + await apiSignout({ page }); + } + }); + + test('owner filter with unknown user ids returns nothing', async ({ page }) => { + const { ownerA, teamA } = await seedScenario(); + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, procedure, { ownerIds: [-1, 999999999] }, teamA.id); + + expectNoResults(res); + }); + + test('owner filter is ignored by the public findTemplates route', async ({ page }) => { + const { ownerA, memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario(); + + await apiSignin({ page, email: memberA.email }); + + // The public route does not accept ownerIds. It should be dropped, not error. + const res = await trpcQuery(page, 'template.findTemplates', { ownerIds: [ownerA.id] }, teamA.id); + + expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id); + }); +}); + +// ─── Folder filter must not reach other teams ──────────────────────────────── + +test.describe('Find Templates API - Adversarial: Folder Filter', () => { + for (const procedure of FIND_TEMPLATE_PROCEDURES) { + test(`${procedure}: folder filter must not find folders from another team`, async ({ page }) => { + const { ownerA, teamA, memberB, teamB } = await seedScenario(); + + const folderA = await seedBlankFolder(ownerA, teamA.id, { + createFolderOptions: { type: FolderType.TEMPLATE }, + }); + + await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Foldered Template ${nanoid()}`, + visibility: DocumentVisibility.EVERYONE, + folderId: folderA.id, + }, + }); + + await apiSignin({ page, email: memberB.email }); + + const res = await trpcQuery(page, procedure, { folderId: folderA.id }, teamB.id); + + expectNoResults(res); + }); + } +}); diff --git a/packages/lib/server-only/template/build-template-search-filter.ts b/packages/lib/server-only/template/build-template-search-filter.ts new file mode 100644 index 000000000..890f9b2ab --- /dev/null +++ b/packages/lib/server-only/template/build-template-search-filter.ts @@ -0,0 +1,34 @@ +import type { Prisma } from '@prisma/client'; + +/** + * Builds the search clause for template listings. + * + * Matches the same fields as the documents search so both pages behave the + * same way: title, external ID, and recipient name or email. + * + * Returns `null` when the query is empty so callers can skip the clause. + */ +export const buildTemplateSearchFilter = (query?: string): Prisma.EnvelopeWhereInput | null => { + const searchQuery = query?.trim() ?? ''; + + if (searchQuery.length === 0) { + return null; + } + + return { + OR: [ + { title: { contains: searchQuery, mode: 'insensitive' } }, + { externalId: { contains: searchQuery, mode: 'insensitive' } }, + { + recipients: { + some: { + OR: [ + { name: { contains: searchQuery, mode: 'insensitive' } }, + { email: { contains: searchQuery, mode: 'insensitive' } }, + ], + }, + }, + }, + ], + }; +}; diff --git a/packages/lib/server-only/template/find-organisation-templates.ts b/packages/lib/server-only/template/find-organisation-templates.ts index 77bdddcb7..0c612417a 100644 --- a/packages/lib/server-only/template/find-organisation-templates.ts +++ b/packages/lib/server-only/template/find-organisation-templates.ts @@ -5,12 +5,14 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { FindResultResponse } from '../../types/search-params'; import { getMemberRoles } from '../team/get-member-roles'; import { getTeamById } from '../team/get-team'; +import { buildTemplateSearchFilter } from './build-template-search-filter'; export type FindOrganisationTemplatesOptions = { userId: number; teamId: number; page?: number; perPage?: number; + query?: string; }; export const findOrganisationTemplates = async ({ @@ -18,6 +20,7 @@ export const findOrganisationTemplates = async ({ teamId, page = 1, perPage = 10, + query, }: FindOrganisationTemplatesOptions) => { const [team, { teamRole }] = await Promise.all([ getTeamById({ teamId, userId }), @@ -30,6 +33,8 @@ export const findOrganisationTemplates = async ({ }), ]); + const searchFilter = buildTemplateSearchFilter(query); + const where: Prisma.EnvelopeWhereInput = { type: EnvelopeType.TEMPLATE, templateType: TemplateType.ORGANISATION, @@ -39,6 +44,7 @@ export const findOrganisationTemplates = async ({ team: { organisationId: team.organisationId, }, + AND: searchFilter ? [searchFilter] : undefined, }; const templateInclude = { diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 7b838c01c..1f87275b8 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -5,6 +5,7 @@ import { EnvelopeType, type Prisma } from '@prisma/client'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { FindResultResponse } from '../../types/search-params'; import { getMemberRoles } from '../team/get-member-roles'; +import { buildTemplateSearchFilter } from './build-template-search-filter'; export type FindTemplatesOptions = { userId: number; @@ -13,6 +14,8 @@ export type FindTemplatesOptions = { page?: number; perPage?: number; folderId?: string; + query?: string; + ownerIds?: number[]; }; export const findTemplates = async ({ @@ -22,6 +25,8 @@ export const findTemplates = async ({ page = 1, perPage = 10, folderId, + query, + ownerIds, }: FindTemplatesOptions) => { const { teamRole } = await getMemberRoles({ teamId, @@ -31,23 +36,35 @@ export const findTemplates = async ({ }, }); + const filters: Prisma.EnvelopeWhereInput[] = [ + { teamId }, + { + OR: [ + { + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole], + }, + }, + { userId, teamId }, + ], + }, + folderId ? { folderId } : { folderId: null }, + ]; + + if (ownerIds && ownerIds.length > 0) { + filters.push({ userId: { in: ownerIds } }); + } + + const searchFilter = buildTemplateSearchFilter(query); + + if (searchFilter) { + filters.push(searchFilter); + } + const where: Prisma.EnvelopeWhereInput = { type: EnvelopeType.TEMPLATE, templateType: type, - AND: [ - { teamId }, - { - OR: [ - { - visibility: { - in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole], - }, - }, - { userId, teamId }, - ], - }, - folderId ? { folderId } : { folderId: null }, - ], + AND: filters, }; const templateInclude = { diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts index 4f4eda1e0..097630ee5 100644 --- a/packages/lib/utils/templates.ts +++ b/packages/lib/utils/templates.ts @@ -1,8 +1,10 @@ -import type { Envelope, Recipient } from '@prisma/client'; +import type { DocumentMeta, Envelope, Field, Recipient } from '@prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import type { TTemplateLite } from '../types/template'; import { mapSecondaryIdToTemplateId } from './envelope'; +import { mapFieldToLegacyField } from './fields'; +import { mapRecipientToLegacyRecipient } from './recipients'; export const formatDirectTemplatePath = (token: string) => { return `${NEXT_PUBLIC_WEBAPP_URL()}/d/${token}`; @@ -67,3 +69,42 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite => templateDocumentDataId: '', }; }; + +type EnvelopeWithTemplateManyRelations = Envelope & { + team: { id: number; url: string; name: string } | null; + fields: Field[]; + recipients: Recipient[]; + documentMeta: DocumentMeta | null; + directLink: { token: string; enabled: boolean } | null; +}; + +/** + * Maps an envelope (with the relations loaded by the template find functions) + * to the legacy "template many" response shape. + */ +export const mapEnvelopeToTemplateMany = (envelope: EnvelopeWithTemplateManyRelations) => { + const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId); + + return { + id: legacyTemplateId, + envelopeId: envelope.id, + type: envelope.templateType, + visibility: envelope.visibility, + externalId: envelope.externalId, + title: envelope.title, + userId: envelope.userId, + teamId: envelope.teamId, + authOptions: envelope.authOptions, + createdAt: envelope.createdAt, + updatedAt: envelope.updatedAt, + publicTitle: envelope.publicTitle, + publicDescription: envelope.publicDescription, + folderId: envelope.folderId, + useLegacyFieldInsertion: envelope.useLegacyFieldInsertion, + team: envelope.team, + fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)), + recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)), + templateMeta: envelope.documentMeta, + directLink: envelope.directLink, + }; +}; diff --git a/packages/trpc/server/template-router/find-templates-internal.ts b/packages/trpc/server/template-router/find-templates-internal.ts new file mode 100644 index 000000000..dd3e2d182 --- /dev/null +++ b/packages/trpc/server/template-router/find-templates-internal.ts @@ -0,0 +1,39 @@ +import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; +import { mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindTemplatesInternalRequestSchema, + ZFindTemplatesInternalResponseSchema, +} from './find-templates-internal.types'; + +export const findTemplatesInternalRoute = authenticatedProcedure + .input(ZFindTemplatesInternalRequestSchema) + .output(ZFindTemplatesInternalResponseSchema) + .query(async ({ input, ctx }) => { + const { user, teamId } = ctx; + + const { query, type, folderId, page, perPage, ownerIds } = input; + + ctx.logger.info({ + input: { + folderId, + }, + }); + + const result = await findTemplates({ + userId: user.id, + teamId, + query, + type, + folderId, + page, + perPage, + ownerIds, + }); + + return { + ...result, + data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)), + }; + }); diff --git a/packages/trpc/server/template-router/find-templates-internal.types.ts b/packages/trpc/server/template-router/find-templates-internal.types.ts new file mode 100644 index 000000000..5d0f94eb4 --- /dev/null +++ b/packages/trpc/server/template-router/find-templates-internal.types.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { ZFindTemplatesRequestSchema, ZFindTemplatesResponseSchema } from './schema'; + +export const ZFindTemplatesInternalRequestSchema = ZFindTemplatesRequestSchema.extend({ + ownerIds: z.array(z.number()).optional(), +}); + +export const ZFindTemplatesInternalResponseSchema = ZFindTemplatesResponseSchema; + +export type TFindTemplatesInternalRequest = z.infer; +export type TFindTemplatesInternalResponse = z.infer; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 00c4816f8..e7a7a9d90 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -27,15 +27,14 @@ import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; -import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; -import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients'; -import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates'; +import { mapEnvelopeToTemplateLite, mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates'; import { prisma } from '@documenso/prisma'; import type { Envelope } from '@prisma/client'; import { DocumentDataType, EnvelopeType } from '@prisma/client'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; +import { findTemplatesInternalRoute } from './find-templates-internal'; import { getTemplatesByIdsRoute } from './get-templates-by-ids'; import { ZBulkSendTemplateMutationSchema, @@ -102,35 +101,15 @@ export const templateRouter = router({ // Remapping for backwards compatibility. return { ...result, - data: result.data.map((envelope) => { - const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId); - - return { - id: legacyTemplateId, - envelopeId: envelope.id, - type: envelope.templateType, - visibility: envelope.visibility, - externalId: envelope.externalId, - title: envelope.title, - userId: envelope.userId, - teamId: envelope.teamId, - authOptions: envelope.authOptions, - createdAt: envelope.createdAt, - updatedAt: envelope.updatedAt, - publicTitle: envelope.publicTitle, - publicDescription: envelope.publicDescription, - folderId: envelope.folderId, - useLegacyFieldInsertion: envelope.useLegacyFieldInsertion, - team: envelope.team, - fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)), - recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)), - templateMeta: envelope.documentMeta, - directLink: envelope.directLink, - }; - }), + data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)), }; }), + /** + * @private + */ + findTemplatesInternal: findTemplatesInternalRoute, + /** * @private */ @@ -149,32 +128,7 @@ export const templateRouter = router({ // Remapping for backwards compatibility. return { ...result, - data: result.data.map((envelope) => { - const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId); - - return { - id: legacyTemplateId, - envelopeId: envelope.id, - type: envelope.templateType, - visibility: envelope.visibility, - externalId: envelope.externalId, - title: envelope.title, - userId: envelope.userId, - teamId: envelope.teamId, - authOptions: envelope.authOptions, - createdAt: envelope.createdAt, - updatedAt: envelope.updatedAt, - publicTitle: envelope.publicTitle, - publicDescription: envelope.publicDescription, - folderId: envelope.folderId, - useLegacyFieldInsertion: envelope.useLegacyFieldInsertion, - team: envelope.team, - fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)), - recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)), - templateMeta: envelope.documentMeta, - directLink: envelope.directLink, - }; - }), + data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)), }; }),