Compare commits

...

3 Commits

Author SHA1 Message Date
David Nguyen f836e1405d fix: stuff 2026-07-18 18:47:35 +09:00
David Nguyen e8531d8de0 fix: stuff 2026-07-18 18:47:21 +09:00
David Nguyen 051899531c feat: rework command search 2026-07-18 12:32:27 +09:00
11 changed files with 2064 additions and 233 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
import type { MessageDescriptor } from '@lingui/core';
import type { LucideIcon } from 'lucide-react';
export type PromptItem = {
id: string;
label: string | MessageDescriptor;
sublabel?: string;
path?: string;
onAction?: () => void;
icon?: LucideIcon;
initials?: string;
shortcut?: string;
isChecked?: boolean;
};
export type PromptCategory = {
id: string;
label: MessageDescriptor;
items: PromptItem[];
/**
* The number of actual results, excluding utility rows such as the
* "View all results" link.
*/
count: number;
/**
* The count shown on the category chip, or null to not show a chip at all.
* Categories which only contain hardcoded page links have no chip.
*/
chipCount: number | null;
isCapped: boolean;
/**
* Global admin categories are marked with a globe icon to distinguish them
* from the equally named personal categories.
*/
isGlobal: boolean;
};
@@ -0,0 +1,144 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types';
import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { keepPreviousData } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react';
import { useMemo } from 'react';
import type { PromptCategory, PromptItem } from './app-command-menu.types';
/**
* The maximum number of results the admin search returns per resource type.
*/
const ADMIN_SEARCH_RESULTS_CAP = 5;
const ADMIN_GROUP_LABELS: Record<TAdminSearchResultType, MessageDescriptor> = {
document: msg`Documents`,
user: msg`Users`,
organisation: msg`Organisations`,
team: msg`Teams`,
recipient: msg`Recipients`,
subscription: msg`Subscriptions`,
};
const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
document: FileTextIcon,
user: UserIcon,
organisation: Building2Icon,
team: UsersIcon,
recipient: UserIcon,
subscription: CreditCardIcon,
};
/**
* Admin list pages which support prefilling their search from the URL, used
* for the "View all results" links on capped groups. Teams, recipients and
* subscriptions have no admin list pages.
*/
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
};
export type UseAdminSearchCategoriesOptions = {
/**
* The trimmed, debounced search query.
*/
query: string;
open: boolean;
};
/**
* The isolated admin portion of the command prompt: searches every admin
* resource and maps the results to prompt categories marked as global.
*
* Returns no categories and never queries for non admin users. The admin
* search endpoint is additionally guarded server side by the admin procedure.
*/
export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => {
const { user } = useSession();
const isUserAdmin = isAdmin(user);
// Admin searches hit every resource table, so require a longer query unless
// it is a number, which could be a resource ID of any length. Queries over
// the endpoint's length limit are skipped entirely instead of being sent
// and rejected.
const hasValidAdminSearch =
isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query));
const {
data: adminSearchData,
isFetching,
isError,
} = trpcReact.admin.search.useQuery(
{
query,
},
{
enabled: open && hasValidAdminSearch,
placeholderData: keepPreviousData,
// Retyping is the retry in a search-as-you-type flow: fail fast so the
// prompt can surface an honest error state instead of retrying.
retry: false,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const categories = useMemo((): PromptCategory[] => {
if (!hasValidAdminSearch || !adminSearchData) {
return [];
}
return adminSearchData.groups.map((group) => {
const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP;
const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type];
const items: PromptItem[] = group.results.map((result) => ({
id: `admin-${group.type}-${result.value}`,
label: result.label,
sublabel: result.sublabel,
path: result.path,
icon: ADMIN_GROUP_ICONS[group.type],
initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined,
}));
// Capped groups link to the full admin list page with the search
// prefilled so the cap is never a dead end.
if (isCapped && buildListPath) {
items.push({
id: `admin-${group.type}-view-all`,
label: msg`View all results`,
path: buildListPath(query),
icon: ArrowRightIcon,
});
}
return {
id: `admin-${group.type}`,
label: ADMIN_GROUP_LABELS[group.type],
items,
count: group.results.length,
chipCount: group.results.length,
isCapped,
isGlobal: true,
};
});
}, [hasValidAdminSearch, adminSearchData, query]);
return {
isUserAdmin,
categories,
isFetching,
isError,
};
};
@@ -0,0 +1,439 @@
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../fixtures/authentication';
import { openCommandMenu } from '../fixtures/command-menu';
test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id));
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
// The category chips include the admin groups with their result counts.
await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible();
const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first();
// Admin results are real links so they support native link behaviour such
// as opening in a new tab.
await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`);
await userOption.click();
await page.waitForURL(`/admin/users/${targetUser.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { team: targetTeam } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id));
await expect(page.getByText('Global Teams', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click();
await page.waitForURL(`/admin/teams/${targetTeam.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click();
await page.waitForURL(`/admin/documents/${document.id}`);
});
test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id);
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
// A 3 character non-numeric query must not trigger the admin search. The
// personal document search fires for any non-empty query, so its response
// is the synchronization anchor proving the debounced queries have fired.
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
await input.fill('abc');
await documentSearchResponse;
await expect(page.getByText(/^Global /)).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
// A numeric query fires regardless of length.
const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search'));
await input.fill('7');
await adminSearchRequest;
});
test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
const initialY = (await input.boundingBox())?.y;
expect(initialY).toBeGreaterThan(0);
// The height of the prompt may change as results come and go, but the
// search bar must never move.
await input.fill(String(targetUser.id));
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
const resultsY = (await input.boundingBox())?.y;
expect(resultsY).toBe(initialY);
// The search bar must not move when there are no results at all.
await input.fill('zzzz-no-such-thing-9x7q');
await expect(page.getByText('No results for')).toBeVisible();
const emptyY = (await input.boundingBox())?.y;
expect(emptyY).toBe(initialY);
});
test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// Admin pages have no current team, the page links must still show.
await page.goto('/admin/stats');
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible();
// Chips only show for categories with actual results, not for the
// hardcoded page links.
await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
// The sub page has a contextual placeholder and a back option.
await expect(page.getByPlaceholder('Search themes…')).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click();
await expect(page.locator('html')).toHaveClass(/dark/);
// The back option returns to the root view.
await page.getByRole('option').filter({ hasText: 'Back' }).first().click();
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const namePrefix = `viewall-${nanoid()}`;
// Seed enough users sharing a name prefix to hit the 5 result cap.
for (let i = 0; i < 5; i++) {
await seedUser({ name: `${namePrefix}-${i}` });
}
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix);
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
'href',
`/admin/users?search=${encodeURIComponent(namePrefix)}`,
);
});
test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: firstUser } = await seedUser();
const { user: secondUser } = await seedUser();
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
// First search selects the first result.
await input.fill(String(firstUser.id));
await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible();
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
// A subsequent search with entirely new results must select the first
// result again.
await input.fill(String(secondUser.id));
await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible();
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
});
test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
// "setg" is a non-contiguous abbreviation of "Settings".
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg');
// Wait for the debounced filter to apply first, "Draft documents" can
// never match "setg" under either matching strategy.
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0);
await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await expect
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
.toBe('hidden');
await page.keyboard.press('Escape');
await expect
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
.toBe('visible');
});
test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => {
const { user, team } = await seedUser({ isAdmin: false });
const document = await seedPendingDocument(user, team.id, []);
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: user.email });
// Non-admins get the same prompt with a non-admin placeholder.
await openCommandMenu(page, 'Type a command or search...');
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
// Wait for the regular (non-admin) search to resolve so we know the
// debounced queries have fired.
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
await expect(page.getByText(/^Global /)).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const searchRequests: string[] = [];
page.on('request', (request) => {
if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) {
searchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
const input = page.getByPlaceholder('Search themes…');
await expect(input).toBeVisible();
// Long enough to pass the admin search threshold if it were enabled.
await input.fill('dark');
// The client-side filter applying proves the typing registered.
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0);
// Wait out the 200ms search debounce with a wide margin before asserting
// that no requests fired: there is no response to anchor on when the
// desired behaviour is "no requests at all".
await page.waitForTimeout(750);
expect(searchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q');
// A failed search must be honest about it, not claim there are no results.
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByText('No results for')).toHaveCount(0);
});
test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => {
const { user: adminUser, team } = await seedUser({ isAdmin: true });
const document = await seedPendingDocument(adminUser, team.id, [], {
createDocumentOptions: { title: `partial-fail-${nanoid()}` },
});
// Only the admin search fails: the personal searches succeed.
await page.route(/api\/trpc\/admin\.search/, async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
// The successful personal document search must still render its results.
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
// The failed admin search must be flagged rather than silently dropped.
await expect(page.getByText('Some searches failed')).toBeVisible();
});
test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const adminSearchRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('admin.search')) {
adminSearchRequests.push(request.url());
}
});
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
// The admin search endpoint rejects queries longer than 100 characters, so
// the client must not send them. The personal searches accept up to 1024
// characters and still run, anchoring the debounced query flush.
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150));
await documentSearchResponse;
// The personal searches ran and found nothing: the honest empty state, with
// no error in sight.
await expect(page.getByText('No results for')).toBeVisible();
await expect(page.getByText('Something went wrong')).toHaveCount(0);
expect(adminSearchRequests).toHaveLength(0);
});
@@ -0,0 +1,249 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
type AdminSearchGroup = {
type: string;
results: Array<{ label: string; sublabel?: string; path: string; value: string }>;
};
const callAdminSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
groups: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json.groups as AdminSearchGroup[])
: null,
};
};
const findGroup = (groups: AdminSearchGroup[] | null, type: string) =>
(groups ?? []).find((group) => group.type === type);
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => {
const { res } = await callAdminSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({ page, email: nonAdminUser.email });
const { res } = await callAdminSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
// ─── Numeric queries: verified ID lookups ────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: targetUser, team: targetTeam } = await seedUser();
await apiSignin({ page, email: adminUser.email });
// Search by user ID.
const userSearch = await callAdminSearch(page, String(targetUser.id));
expect(userSearch.res.ok()).toBeTruthy();
const userGroup = findGroup(userSearch.groups, 'user');
expect(userGroup).toBeDefined();
expect(userGroup?.results).toHaveLength(1);
expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`);
expect(userGroup?.results[0].sublabel).toContain(targetUser.email);
// The cmdk `value` contract: value must contain the raw query.
expect(userGroup?.results[0].value).toContain(String(targetUser.id));
// Search by team ID.
const teamSearch = await callAdminSearch(page, String(targetTeam.id));
expect(teamSearch.res.ok()).toBeTruthy();
const teamGroup = findGroup(teamSearch.groups, 'team');
expect(teamGroup).toBeDefined();
expect(teamGroup?.results).toHaveLength(1);
expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`);
expect(teamGroup?.results[0].label).toBe(targetTeam.name);
});
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const { user: recipientUser } = await seedUser();
const document = await seedPendingDocument(sender, team.id, [recipientUser]);
const legacyDocumentId = document.secondaryId.replace('document_', '');
const recipient = document.recipients[0];
await apiSignin({ page, email: adminUser.email });
// Search by legacy document ID (bare number).
const documentSearch = await callAdminSearch(page, legacyDocumentId);
expect(documentSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(documentSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results).toHaveLength(1);
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
expect(documentGroup?.results[0].label).toBe(document.title);
// Search by recipient ID: links to the parent document.
const recipientSearch = await callAdminSearch(page, String(recipient.id));
expect(recipientSearch.res.ok()).toBeTruthy();
const recipientGroup = findGroup(recipientSearch.groups, 'recipient');
expect(recipientGroup).toBeDefined();
expect(recipientGroup?.results).toHaveLength(1);
expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
expect(recipientGroup?.results[0].label).toBe(recipient.email);
expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`);
// Search by the full document_<id> secondary ID: exercises the prefix branch.
const secondaryIdSearch = await callAdminSearch(page, document.secondaryId);
expect(secondaryIdSearch.res.ok()).toBeTruthy();
const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document');
expect(secondaryIdGroup).toBeDefined();
expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
});
test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, '999999999');
expect(res.ok()).toBeTruthy();
expect(groups).toEqual([]);
});
test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
// treated as text (and must not 500).
const oversizedNumber = '99999999999999';
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, oversizedNumber);
expect(res.ok()).toBeTruthy();
const documentGroup = findGroup(groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
});
// ─── Prefixed ID queries: exact lookups ──────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, organisation, team } = await seedUser();
const document = await seedPendingDocument(sender, team.id, []);
await apiSignin({ page, email: adminUser.email });
// envelope_<id> resolves the document.
const envelopeSearch = await callAdminSearch(page, document.id);
expect(envelopeSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(envelopeSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
// Only the document group is returned for a recognized prefix.
expect(envelopeSearch.groups).toHaveLength(1);
// org_<id> resolves the organisation.
const orgSearch = await callAdminSearch(page, organisation.id);
expect(orgSearch.res.ok()).toBeTruthy();
const orgGroup = findGroup(orgSearch.groups, 'organisation');
expect(orgGroup).toBeDefined();
expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`);
expect(orgGroup?.results[0].label).toBe(organisation.name);
// Only the organisation group is returned for a recognized prefix.
expect(orgSearch.groups).toHaveLength(1);
});
// ─── Free text queries ───────────────────────────────────────────────────────
test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// A unique title: the default seeded title is shared across the whole suite,
// and global search only returns the newest few matches.
const document = await seedPendingDocument(sender, team.id, [], {
createDocumentOptions: { title: `admin-search-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Search by document title.
const titleSearch = await callAdminSearch(page, document.title);
expect(titleSearch.res.ok()).toBeTruthy();
const documentGroup = findGroup(titleSearch.groups, 'document');
expect(documentGroup).toBeDefined();
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
// Search by user email (emails are unique nanoid-based, so this is specific).
const emailSearch = await callAdminSearch(page, sender.email);
expect(emailSearch.res.ok()).toBeTruthy();
const userGroup = findGroup(emailSearch.groups, 'user');
expect(userGroup).toBeDefined();
expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`);
});
test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q');
expect(res.ok()).toBeTruthy();
expect(groups).toEqual([]);
});
@@ -3,6 +3,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { openCommandMenu } from '../fixtures/command-menu';
const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...';
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
const { user, team } = await seedUser();
@@ -14,9 +17,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
email: user.email,
});
await page.keyboard.press('Meta+K');
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
@@ -30,9 +33,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
email: recipient.email,
});
await page.keyboard.press('Meta+K');
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
@@ -46,8 +49,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
email: user.email,
});
await page.keyboard.press('Meta+K');
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email);
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
});
@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Opens the app command menu via the keyboard shortcut.
*
* Retries the shortcut until the menu appears since the keypress is a no-op
* when it happens before the page has hydrated.
*
* @param placeholder The search input placeholder to wait for, which differs
* between admin and non-admin users.
*/
export const openCommandMenu = async (page: Page, placeholder: string) => {
await expect(async () => {
await page.keyboard.press('Meta+K');
await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
};
@@ -0,0 +1,372 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
const MAX_POSTGRES_INT = 2147483647;
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
export type AdminGlobalSearchResult = {
label: string;
sublabel?: string;
path: string;
value: string;
};
export type AdminGlobalSearchGroup = {
type: AdminGlobalSearchResultType;
results: AdminGlobalSearchResult[];
};
export type AdminGlobalSearchOptions = {
query: string;
};
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return [];
}
const resultsByType = await resolveSearch(trimmedQuery);
return GROUP_ORDER.map((type) => ({
type,
results: (resultsByType[type] ?? []).map((result) => ({
...result,
// Append the raw query so cmdk's client-side filter never hides
// server-verified results.
value: `${result.value} ${trimmedQuery}`,
})),
})).filter((group) => group.results.length > 0);
};
const resolveSearch = async (query: string): Promise<PartialResults> => {
// Recognized ID prefixes resolve to a single exact lookup.
if (query.startsWith('envelope_')) {
return { document: await findDocumentsByExactId({ id: query }) };
}
if (query.startsWith('document_')) {
return { document: await findDocumentsByExactId({ secondaryId: query }) };
}
if (query.startsWith('org_')) {
return { organisation: await findOrganisationsByIdOrUrl(query) };
}
// Bare numbers are treated as verified ID lookups only. Oversized numbers
// fall through to text search.
const numericId = Number(query);
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
const [document, user, team, recipient, subscription] = await Promise.all([
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
findUsersById(numericId),
findTeamsById(numericId),
findRecipientsById(numericId),
findSubscriptionsById(numericId),
]);
return { document, user, team, recipient, subscription };
}
// Free text searches all resource types in parallel.
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
findDocumentsByText(query),
findUsersByText(query),
findOrganisationsByText(query),
findTeamsByText(query),
findRecipientsByText(query),
findSubscriptionsByText(query),
]);
return {
document,
user,
organisation,
team,
recipient,
subscription,
};
};
const joinSublabel = (parts: Array<string | null | undefined>) =>
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
// ─── Documents ────────────────────────────────────────────────────────────────
const documentSelect = {
id: true,
title: true,
secondaryId: true,
user: { select: { email: true } },
} as const;
type DocumentRow = {
id: string;
title: string;
secondaryId: string;
user: { email: string };
};
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
label: envelope.title,
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
path: `/admin/documents/${envelope.id}`,
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
});
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
const envelope = await prisma.envelope.findFirst({
where: { ...where, type: EnvelopeType.DOCUMENT },
select: documentSelect,
});
return envelope ? [mapDocument(envelope)] : [];
};
const findDocumentsByText = async (query: string) => {
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
title: { contains: query, mode: 'insensitive' },
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: documentSelect,
});
return envelopes.map(mapDocument);
};
// ─── Users ────────────────────────────────────────────────────────────────────
const userSelect = {
id: true,
name: true,
email: true,
} as const;
type UserRow = { id: number; name: string | null; email: string };
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
label: user.name || user.email,
sublabel: joinSublabel([`#${user.id}`, user.email]),
path: `/admin/users/${user.id}`,
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
});
const findUsersById = async (id: number) => {
const user = await prisma.user.findFirst({
where: { id },
select: userSelect,
});
return user ? [mapUser(user)] : [];
};
const findUsersByText = async (query: string) => {
const users = await prisma.user.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: userSelect,
});
return users.map(mapUser);
};
// ─── Organisations ────────────────────────────────────────────────────────────
const organisationSelect = {
id: true,
name: true,
owner: { select: { email: true } },
} as const;
type OrganisationRow = { id: string; name: string; owner: { email: string } };
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
label: organisation.name,
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
path: `/admin/organisations/${organisation.id}`,
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
});
const findOrganisationsByIdOrUrl = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [{ id: query }, { url: query }],
},
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
const findOrganisationsByText = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ url: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
{ owner: { email: { contains: query, mode: 'insensitive' } } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
// ─── Teams ────────────────────────────────────────────────────────────────────
const teamSelect = {
id: true,
name: true,
url: true,
organisation: { select: { name: true } },
} as const;
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
label: team.name,
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
path: `/admin/teams/${team.id}`,
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
});
const findTeamsById = async (id: number) => {
const team = await prisma.team.findFirst({
where: { id },
select: teamSelect,
});
return team ? [mapTeam(team)] : [];
};
const findTeamsByText = async (query: string) => {
const teams = await prisma.team.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: teamSelect,
});
return teams.map(mapTeam);
};
// ─── Recipients ───────────────────────────────────────────────────────────────
const recipientSelect = {
id: true,
name: true,
email: true,
envelope: { select: { id: true, title: true } },
} as const;
type RecipientRow = {
id: number;
name: string;
email: string;
envelope: { id: string; title: string };
};
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
label: recipient.email,
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
path: `/admin/documents/${recipient.envelope.id}`,
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
});
const findRecipientsById = async (id: number) => {
const recipient = await prisma.recipient.findFirst({
where: {
id,
envelope: { type: EnvelopeType.DOCUMENT },
},
select: recipientSelect,
});
return recipient ? [mapRecipient(recipient)] : [];
};
const findRecipientsByText = async (query: string) => {
const recipients = await prisma.recipient.findMany({
where: {
envelope: { type: EnvelopeType.DOCUMENT },
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: recipientSelect,
});
return recipients.map(mapRecipient);
};
// ─── Subscriptions ────────────────────────────────────────────────────────────
const subscriptionSelect = {
id: true,
status: true,
planId: true,
customerId: true,
organisationId: true,
} as const;
type SubscriptionRow = {
id: number;
status: string;
planId: string;
customerId: string;
organisationId: string;
};
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
label: `Subscription #${subscription.id}`,
sublabel: joinSublabel([subscription.status, subscription.planId]),
path: `/admin/organisations/${subscription.organisationId}`,
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
});
const findSubscriptionsById = async (id: number) => {
const subscription = await prisma.subscription.findFirst({
where: { id },
select: subscriptionSelect,
});
return subscription ? [mapSubscription(subscription)] : [];
};
const findSubscriptionsByText = async (query: string) => {
const subscriptions = await prisma.subscription.findMany({
where: {
OR: [
{ planId: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: subscriptionSelect,
});
return subscriptions.map(mapSubscription);
};
@@ -0,0 +1,15 @@
import { adminGlobalSearch } from '@documenso/lib/server-only/admin/admin-global-search';
import { adminProcedure } from '../trpc';
import { ZAdminSearchRequestSchema, ZAdminSearchResponseSchema } from './admin-search.types';
export const adminSearchRoute = adminProcedure
.input(ZAdminSearchRequestSchema)
.output(ZAdminSearchResponseSchema)
.query(async ({ input }) => {
const { query } = input;
const groups = await adminGlobalSearch({ query });
return { groups };
});
@@ -0,0 +1,37 @@
import { z } from 'zod';
export const ZAdminSearchResultTypeSchema = z.enum([
'document',
'user',
'organisation',
'team',
'recipient',
'subscription',
]);
export const ZAdminSearchResultSchema = z.object({
label: z.string(),
sublabel: z.string().optional(),
path: z.string(),
value: z.string(),
});
export const ADMIN_SEARCH_MAX_QUERY_LENGTH = 100;
export const ZAdminSearchRequestSchema = z.object({
query: z.string().trim().min(1).max(ADMIN_SEARCH_MAX_QUERY_LENGTH),
});
export const ZAdminSearchResponseSchema = z.object({
groups: z.array(
z.object({
type: ZAdminSearchResultTypeSchema,
results: ZAdminSearchResultSchema.array(),
}),
),
});
export type TAdminSearchResultType = z.infer<typeof ZAdminSearchResultTypeSchema>;
export type TAdminSearchResult = z.infer<typeof ZAdminSearchResultSchema>;
export type TAdminSearchRequest = z.infer<typeof ZAdminSearchRequestSchema>;
export type TAdminSearchResponse = z.infer<typeof ZAdminSearchResponseSchema>;
@@ -1,4 +1,5 @@
import { router } from '../trpc';
import { adminSearchRoute } from './admin-search';
import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
@@ -118,5 +119,6 @@ export const adminRouter = router({
teamMember: {
delete: deleteAdminTeamMemberRoute,
},
search: adminSearchRoute,
updateSiteSetting: updateSiteSettingRoute,
});