feat: replace template view tabs with a filter pill (#3148)

This commit is contained in:
Ephraim Duncan
2026-09-17 10:24:19 +10:00
committed by GitHub
parent da934c01a6
commit cf326f825d
20 changed files with 1050 additions and 134 deletions
@@ -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) {
@@ -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);
@@ -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 (
<Input
@@ -0,0 +1,42 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { templatesSearchParams } from '~/utils/templates-search-params';
export const TemplateSearch = () => {
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 (
<Input
type="search"
placeholder={_(msg`Search templates...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
data-testid="templates-search-input"
/>
);
};
@@ -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(),
]);
}}
/>
</DropdownMenu>
@@ -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 (
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Owner</Trans>}
value={selectedOwnerIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="templates-table-owner-filter"
/>
);
};
@@ -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: <Trans>Team</Trans> },
{ value: 'organisation', label: <Trans>Organisation</Trans> },
];
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 (
<FilterPill
icon={Building2Icon}
label={<Trans>View</Trans>}
value={view}
onChange={onChange}
options={VIEW_OPTIONS}
testId="templates-table-view-filter"
/>
);
};
@@ -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<RowSelectionState>(
@@ -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 (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -112,31 +120,25 @@ export default function TemplatesPage() {
</h1>
</div>
{showOrgTab && (
<div className="mt-6">
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
<TabsList>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="team"
data-testid="template-tab-team"
>
<Trans>Team</Trans>
</TabsTrigger>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="organisation"
data-testid="template-tab-organisation"
>
<Trans>Organisation</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<TemplateSearch />
</div>
)}
{showOrgFilter && <TemplatesTableViewFilter />}
{!isOrgView && <TemplatesTableOwnerFilter teamId={team.id} />}
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
<div className="mt-8">
{activeQuery.data && activeQuery.data.count === 0 ? (
{activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<Bird className="h-12 w-12" strokeWidth={1.5} />
@@ -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,
};
@@ -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();
@@ -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,
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record<string,
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
test.describe('Organisation Templates - UI Tabs', () => {
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.
@@ -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<string, unknown>,
teamId?: number,
): Promise<TrpcResponse> => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { page: 1, perPage: 50, ...input } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
const headers: Record<string, string> = 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);
});
}
});
@@ -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' } },
],
},
},
},
],
};
};
@@ -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 = {
@@ -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 = {
+42 -1
View File
@@ -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,
};
};
@@ -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)),
};
});
@@ -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<typeof ZFindTemplatesInternalRequestSchema>;
export type TFindTemplatesInternalResponse = z.infer<typeof ZFindTemplatesInternalResponseSchema>;
+9 -55
View File
@@ -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)),
};
}),