From 8cabeb8e461fc4699e175ba279590085a99a2083 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 1 Nov 2025 14:48:11 -0700 Subject: [PATCH 1/2] Optimize customers search with FULLTEXT index --- apps/web/app/(ee)/api/customers/route.ts | 32 ++++++++++++------------ packages/prisma/schema/customer.prisma | 3 ++- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/web/app/(ee)/api/customers/route.ts b/apps/web/app/(ee)/api/customers/route.ts index 4cc79d84287..ff8f7455889 100644 --- a/apps/web/app/(ee)/api/customers/route.ts +++ b/apps/web/app/(ee)/api/customers/route.ts @@ -12,7 +12,7 @@ import { getCustomersQuerySchemaExtended, } from "@/lib/zod/schemas/customers"; import { DiscountSchemaWithDeprecatedFields } from "@/lib/zod/schemas/discount"; -import { prisma } from "@dub/prisma"; +import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { nanoid, R2_URL } from "@dub/utils"; import { Customer, @@ -65,21 +65,21 @@ export const GET = withWorkspace( ? { email } : externalId ? { externalId } - : { - ...(search && { - OR: [ - { email: { startsWith: search } }, - { externalId: { startsWith: search } }, - { name: { startsWith: search } }, - ], - }), - ...(country && { - country, - }), - ...(linkId && { - linkId, - }), - }), + : search + ? search.includes("@") + ? { email: search } + : { + email: { search: sanitizeFullTextSearch(search) }, + name: { search: sanitizeFullTextSearch(search) }, + externalId: { search: sanitizeFullTextSearch(search) }, + } + : {}), + ...(country && { + country, + }), + ...(linkId && { + linkId, + }), }, orderBy: { [sortBy]: sortOrder, diff --git a/packages/prisma/schema/customer.prisma b/packages/prisma/schema/customer.prisma index 9466d9e1c15..22f11fd4ca2 100644 --- a/packages/prisma/schema/customer.prisma +++ b/packages/prisma/schema/customer.prisma @@ -25,10 +25,11 @@ model Customer { @@unique([projectId, externalId]) @@unique([projectConnectId, externalId]) + @@index([projectId, email]) @@index([projectId, createdAt]) @@index([projectId, saleAmount]) - @@index([projectId, email, externalId, name]) @@index(externalId) @@index(linkId) @@index(country) + @@fulltext([email, name, externalId]) // For full-text search } From 58323fc8f5621a13a5b4cea743910eee44bc7eb8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 1 Nov 2025 17:47:57 -0700 Subject: [PATCH 2/2] finalize customers search to handle external ID too --- .../web/app/(ee)/api/customers/count/route.ts | 40 ++--- apps/web/app/(ee)/api/customers/route.ts | 1 - .../program/payouts/use-payout-filters.tsx | 1 - apps/web/lib/swr/use-customers-count.ts | 2 +- .../customer-table/customer-table.tsx | 4 +- .../customer-table/use-customer-filters.tsx | 20 ++- packages/prisma/schema/customer.prisma | 2 +- packages/ui/src/combobox/index.tsx | 4 +- packages/ui/src/filter/filter-list.tsx | 147 +++++++++++++----- packages/ui/src/filter/filter-select.tsx | 59 +++++-- 10 files changed, 199 insertions(+), 81 deletions(-) diff --git a/apps/web/app/(ee)/api/customers/count/route.ts b/apps/web/app/(ee)/api/customers/count/route.ts index 4a3fff2a9a1..63157e9bec1 100644 --- a/apps/web/app/(ee)/api/customers/count/route.ts +++ b/apps/web/app/(ee)/api/customers/count/route.ts @@ -1,6 +1,6 @@ import { withWorkspace } from "@/lib/auth"; import { getCustomersCountQuerySchema } from "@/lib/zod/schemas/customers"; -import { prisma } from "@dub/prisma"; +import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; @@ -15,25 +15,24 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { ? { email } : externalId ? { externalId } - : { - ...(search && { - OR: [ - { email: { startsWith: search } }, - { externalId: { startsWith: search } }, - { name: { startsWith: search } }, - ], - }), - // only filter by country if not grouping by country - ...(country && - groupBy !== "country" && { - country, - }), - // only filter by linkId if not grouping by linkId - ...(linkId && - groupBy !== "linkId" && { - linkId, - }), - }), + : search + ? search.includes("@") + ? { email: search } + : { + email: { search: sanitizeFullTextSearch(search) }, + name: { search: sanitizeFullTextSearch(search) }, + } + : {}), + // only filter by country if not grouping by country + ...(country && + groupBy !== "country" && { + country, + }), + // only filter by linkId if not grouping by linkId + ...(linkId && + groupBy !== "linkId" && { + linkId, + }), }; // Get customer count by country @@ -63,6 +62,7 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { linkId: "desc", }, }, + take: 10000, }); const links = await prisma.link.findMany({ diff --git a/apps/web/app/(ee)/api/customers/route.ts b/apps/web/app/(ee)/api/customers/route.ts index ff8f7455889..e8583bd759a 100644 --- a/apps/web/app/(ee)/api/customers/route.ts +++ b/apps/web/app/(ee)/api/customers/route.ts @@ -71,7 +71,6 @@ export const GET = withWorkspace( : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, - externalId: { search: sanitizeFullTextSearch(search) }, } : {}), ...(country && { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx index 69c6ec3e527..02f93b8ec18 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx @@ -77,7 +77,6 @@ export function usePayoutFilters() { icon: InvoiceDollar, label: "Invoice", options: [], - hideInFilterDropdown: true, }, ], [payoutsCount, partners, partnersAsync], diff --git a/apps/web/lib/swr/use-customers-count.ts b/apps/web/lib/swr/use-customers-count.ts index c45d244326d..5e42705b541 100644 --- a/apps/web/lib/swr/use-customers-count.ts +++ b/apps/web/lib/swr/use-customers-count.ts @@ -21,7 +21,7 @@ export default function useCustomersCount({ `/api/customers/count${getQueryString( { workspaceId, ...query }, { - include: ["linkId", "country", "search"], + include: ["linkId", "country", "search", "externalId"], }, )}`, fetcher, diff --git a/apps/web/ui/customers/customer-table/customer-table.tsx b/apps/web/ui/customers/customer-table/customer-table.tsx index 767ffad2c8b..a0954713ec6 100644 --- a/apps/web/ui/customers/customer-table/customer-table.tsx +++ b/apps/web/ui/customers/customer-table/customer-table.tsx @@ -294,8 +294,8 @@ export function CustomerTable() { onRemove={onRemove} /> diff --git a/apps/web/ui/customers/customer-table/use-customer-filters.tsx b/apps/web/ui/customers/customer-table/use-customer-filters.tsx index 318b5f64f9c..31f9085e10c 100644 --- a/apps/web/ui/customers/customer-table/use-customer-filters.tsx +++ b/apps/web/ui/customers/customer-table/use-customer-filters.tsx @@ -1,7 +1,7 @@ import useCustomersCount from "@/lib/swr/use-customers-count"; import useWorkspace from "@/lib/swr/use-workspace"; import { LinkLogo, useRouterStuff } from "@dub/ui"; -import { FlagWavy, Hyperlink } from "@dub/ui/icons"; +import { FlagWavy, Hyperlink, SquareUserSparkle2 } from "@dub/ui/icons"; import { COUNTRIES, getApexDomain, getPrettyUrl, nFormatter } from "@dub/utils"; import { useCallback, useMemo } from "react"; @@ -93,16 +93,28 @@ export function useCustomerFilters( }), }, }, + { + key: "externalId", + icon: SquareUserSparkle2, + label: "External ID", + options: [], + meta: { + filterParams: ({ getValue }) => ({ + externalId: getValue(), + }), + }, + }, ], - [countriesCount, linksCount], + [countriesCount, linksCount, slug], ); const activeFilters = useMemo(() => { - const { country, linkId } = searchParamsObj; + const { country, linkId, externalId } = searchParamsObj; return [ ...(country ? [{ key: "country", value: country }] : []), ...(linkId ? [{ key: "linkId", value: linkId }] : []), + ...(externalId ? [{ key: "externalId", value: externalId }] : []), ]; }, [searchParamsObj]); @@ -128,7 +140,7 @@ export function useCustomerFilters( const onRemoveAll = useCallback( () => queryParams({ - del: ["country", "search"], + del: ["country", "linkId", "externalId", "search"], }), [queryParams], ); diff --git a/packages/prisma/schema/customer.prisma b/packages/prisma/schema/customer.prisma index 22f11fd4ca2..2a82f13b36e 100644 --- a/packages/prisma/schema/customer.prisma +++ b/packages/prisma/schema/customer.prisma @@ -31,5 +31,5 @@ model Customer { @@index(externalId) @@index(linkId) @@index(country) - @@fulltext([email, name, externalId]) // For full-text search + @@fulltext([email, name]) // For full-text search } diff --git a/packages/ui/src/combobox/index.tsx b/packages/ui/src/combobox/index.tsx index a493ab07b37..cca2863b66a 100644 --- a/packages/ui/src/combobox/index.tsx +++ b/packages/ui/src/combobox/index.tsx @@ -55,6 +55,7 @@ export type ComboboxProps< searchPlaceholder?: string; emptyState?: ReactNode; createLabel?: (search: string) => ReactNode; + createIcon?: Icon; onCreate?: (search: string) => Promise; buttonProps?: ButtonProps; labelProps?: { className?: string }; @@ -94,6 +95,7 @@ export function Combobox({ searchPlaceholder = "Search...", emptyState, createLabel, + createIcon: CreateIcon = Plus, onCreate, buttonProps, labelProps, @@ -289,7 +291,7 @@ export function Combobox({ {isCreating ? ( ) : ( - + )}
{createLabel?.(search) || `Create "${search}"`} diff --git a/packages/ui/src/filter/filter-list.tsx b/packages/ui/src/filter/filter-list.tsx index bd699267060..548a210725b 100644 --- a/packages/ui/src/filter/filter-list.tsx +++ b/packages/ui/src/filter/filter-list.tsx @@ -1,11 +1,13 @@ import { cn, truncate } from "@dub/utils"; +import { Command } from "cmdk"; import { X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; -import { ReactNode, isValidElement } from "react"; +import { ReactNode, isValidElement, useState } from "react"; import { AnimatedSizeContainer } from "../animated-size-container"; import { Combobox, ComboboxOption } from "../combobox"; import { useKeyboardShortcut } from "../hooks"; +import { Icon } from "../icons"; import { Filter, FilterOption } from "./types"; type FilterListProps = { @@ -190,44 +192,17 @@ export function FilterList({ ); return ( - { - if ( - newOption && - newOption.value !== String(value) - ) { - // Remove the current value and add the new one - onRemove(key, value); - onSelect(key, newOption.value); - } - }} + { - if (option.value === String(value)) { - return; - } - const filterOption = filter.options?.find( - (opt) => - typeof String(opt.value) === "string" && - typeof option.value === "string" - ? String(opt.value).toLowerCase() === - option.value.toLowerCase() - : String(opt.value) === option.value, - ); - return filterOption ? ( - - {filterOption.right} - - ) : null; - }} - placeholder={truncate(optionLabel, 30)} - caret={false} - trigger={OptionDisplay({ - className: "cursor-pointer hover:bg-neutral-50", - })} + selectedOption={selectedOption} + onRemove={onRemove} + onSelect={onSelect} + OptionDisplay={OptionDisplay} + optionLabel={optionLabel} /> ); })() @@ -267,5 +242,101 @@ export function FilterList({ ); } +function FilterCombobox({ + filter, + value, + filterKey, + options, + selectedOption, + onRemove, + onSelect, + OptionDisplay, + optionLabel, +}: { + filter: Filter; + value: FilterOption["value"]; + filterKey: string; + options: ComboboxOption[]; + selectedOption: ComboboxOption | undefined; + onRemove: (key: string, value: FilterOption["value"]) => void; + onSelect: (key: string, value: FilterOption["value"]) => void; + OptionDisplay: ({ className }: { className?: string }) => ReactNode; + optionLabel: string; +}) { + const [search, setSearch] = useState(""); + + // Check if filter has empty options array + const hasEmptyOptions = filter.options && filter.options.length === 0; + + // Create emptyState based on CommandEmpty logic + const emptyState = (() => { + // If the filter has no options, show the search input as an option or "Start typing to search..." + if (hasEmptyOptions) { + if (!search) { + return ( + + Start typing to search... + + ); + } + // When search exists and filter has empty options, the onCreate handler will show the create option + return null; // onCreate will handle showing the option + } + + return ( + + No matching options + + ); + })(); + + return ( + { + if (newOption && newOption.value !== String(value)) { + // Remove the current value and add the new one + onRemove(filterKey, value); + onSelect(filterKey, newOption.value); + } + }} + options={options} + onSearchChange={setSearch} + onCreate={ + hasEmptyOptions && onSelect + ? async (searchValue: string) => { + // Select the search value as a new option + onRemove(filterKey, value); + onSelect(filterKey, searchValue); + return true; + } + : undefined + } + createLabel={hasEmptyOptions ? (searchValue) => searchValue : undefined} + createIcon={filter.icon as Icon} + optionRight={(option) => { + if (option.value === String(value)) { + return; + } + const filterOption = filter.options?.find((opt) => + typeof String(opt.value) === "string" && + typeof option.value === "string" + ? String(opt.value).toLowerCase() === option.value.toLowerCase() + : String(opt.value) === option.value, + ); + return filterOption ? ( + {filterOption.right} + ) : null; + }} + placeholder={truncate(optionLabel, 30)} + caret={false} + emptyState={emptyState} + trigger={OptionDisplay({ + className: "cursor-pointer hover:bg-neutral-50", + })} + /> + ); +} + const isReactNode = (element: any): element is ReactNode => isValidElement(element); diff --git a/packages/ui/src/filter/filter-select.tsx b/packages/ui/src/filter/filter-select.tsx index 3b85f1f1f96..356abe94413 100644 --- a/packages/ui/src/filter/filter-select.tsx +++ b/packages/ui/src/filter/filter-select.tsx @@ -255,25 +255,30 @@ export function FilterSelect({ ); }) ?? ( // Filter options loading state - ( +
-
) +
)} {/* Only render CommandEmpty if not loading */} {(!selectedFilter || selectedFilter.options) && ( - + selectOption(search)} + askAI={askAI} + > {emptyState ? isEmptyStateObject(emptyState) ? emptyState?.[selectedFilterKey ?? "default"] ?? - "No matches" + "No matching options" : emptyState - : "No matches"} + : "No matching options"} )} @@ -423,13 +428,43 @@ function FilterButton({ const CommandEmpty = ({ search, + selectedFilter, + onSelect, askAI, children, }: PropsWithChildren<{ search: string; + selectedFilter?: Filter | null; + onSelect: () => void; askAI?: boolean; }>) => { - if (askAI && search) { + // If the selected filter has no options, show the search input as an option + if ( + selectedFilter && + selectedFilter.options && + selectedFilter.options.length === 0 + ) { + if (!search) + return ( + + Start typing to search... + + ); + + return ( + + ); + } + + // Ask AI option should only be shown if no filter is selected and the user has typed something in the search input + if (!selectedFilter && askAI && search) { return ( @@ -438,13 +473,13 @@ const CommandEmpty = ({

); - } else { - return ( - - {children} - - ); } + + return ( + + {children} + + ); }; const isReactNode = (element: any): element is ReactNode =>