diff --git a/apps/web/app/(ee)/api/cron/export/partners/fetch-partners-batch.ts b/apps/web/app/(ee)/api/cron/export/partners/fetch-partners-batch.ts index 29e111b10d2..dd4cbead102 100644 --- a/apps/web/app/(ee)/api/cron/export/partners/fetch-partners-batch.ts +++ b/apps/web/app/(ee)/api/cron/export/partners/fetch-partners-batch.ts @@ -1,10 +1,8 @@ import { getPartners } from "@/lib/api/partners/get-partners"; -import { partnersExportQuerySchema } from "@/lib/zod/schemas/partners"; -import * as z from "zod/v4"; type PartnerFilters = Omit< - z.infer, - "columns" + Parameters[0], + "page" | "pageSize" > & { programId: string; }; diff --git a/apps/web/app/(ee)/api/cron/export/partners/route.ts b/apps/web/app/(ee)/api/cron/export/partners/route.ts index 7774dba716b..677bad207c2 100644 --- a/apps/web/app/(ee)/api/cron/export/partners/route.ts +++ b/apps/web/app/(ee)/api/cron/export/partners/route.ts @@ -14,9 +14,16 @@ import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { fetchPartnersBatch } from "./fetch-partners-batch"; +const sqlOperatorSchema = z.enum(["IN", "NOT IN"]); + const payloadSchema = partnersExportQuerySchema.extend({ programId: z.string(), userId: z.string(), + partnerTagIdOperator: sqlOperatorSchema.optional(), + groupIdOperator: sqlOperatorSchema.optional(), + countryOperator: sqlOperatorSchema.optional(), + statusOperator: sqlOperatorSchema.optional(), + referredByPartnerIdOperator: sqlOperatorSchema.optional(), }); // POST /api/cron/export/partners - QStash worker for processing large partner exports @@ -29,9 +36,19 @@ export async function POST(req: Request) { rawBody, }); - let { programId, columns, userId, ...filters } = payloadSchema.parse( - JSON.parse(rawBody), - ); + const payload = payloadSchema.parse(JSON.parse(rawBody)); + + const { + programId, + columns, + userId, + partnerTagIdOperator, + groupIdOperator, + countryOperator, + statusOperator, + referredByPartnerIdOperator, + ...filters + } = payload; const user = await prisma.user.findUnique({ where: { @@ -65,10 +82,14 @@ export async function POST(req: Request) { ); } - // Fetch partners in batches and build CSV const allPartners: any[] = []; const partnersFilters = { ...filters, + partnerTagIdOperator, + groupIdOperator, + countryOperator, + statusOperator, + referredByPartnerIdOperator, programId, }; diff --git a/apps/web/app/(ee)/api/partners/count/route.ts b/apps/web/app/(ee)/api/partners/count/route.ts index 462abe0142c..a18289044ba 100644 --- a/apps/web/app/(ee)/api/partners/count/route.ts +++ b/apps/web/app/(ee)/api/partners/count/route.ts @@ -1,42 +1,21 @@ import { getPartnersCount } from "@/lib/api/partners/get-partners-count"; +import { parsePartnerListQuery } from "@/lib/api/partners/parse-partner-filter-params"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { partnersCountQuerySchema } from "@/lib/zod/schemas/partners"; -import { parseFilterValue } from "@dub/utils"; import { NextResponse } from "next/server"; -function parsePartnerFilterParams( - searchParams: Record, -) { - const partnerTagIdParsed = parseFilterValue(searchParams.partnerTagId); - const groupIdParsed = parseFilterValue(searchParams.groupId); - const countryParsed = parseFilterValue(searchParams.country); - - return { - partnerTagId: partnerTagIdParsed?.values, - partnerTagIdOperator: partnerTagIdParsed?.sqlOperator, - groupId: groupIdParsed?.values, - groupIdOperator: groupIdParsed?.sqlOperator, - country: countryParsed?.values, - countryOperator: countryParsed?.sqlOperator, - }; -} - // GET /api/partners/count - get the count of partners for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const filterOverrides = parsePartnerFilterParams(searchParams); - const parsedParams = partnersCountQuerySchema.parse(searchParams); + const parsedParams = parsePartnerListQuery( + searchParams, + partnersCountQuerySchema, + ); const count = await getPartnersCount({ ...parsedParams, - partnerTagId: filterOverrides.partnerTagId ?? parsedParams.partnerTagId, - partnerTagIdOperator: filterOverrides.partnerTagIdOperator, - groupId: filterOverrides.groupId ?? parsedParams.groupId, - groupIdOperator: filterOverrides.groupIdOperator, - country: filterOverrides.country ?? parsedParams.country, - countryOperator: filterOverrides.countryOperator, programId: programId as string, }); diff --git a/apps/web/app/(ee)/api/partners/export/route.ts b/apps/web/app/(ee)/api/partners/export/route.ts index f96f8b9e55b..704aea31603 100644 --- a/apps/web/app/(ee)/api/partners/export/route.ts +++ b/apps/web/app/(ee)/api/partners/export/route.ts @@ -2,6 +2,7 @@ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { formatPartnersForExport } from "@/lib/api/partners/format-partners-for-export"; import { getPartners } from "@/lib/api/partners/get-partners"; import { getPartnersCount } from "@/lib/api/partners/get-partners-count"; +import { parsePartnerListQuery } from "@/lib/api/partners/parse-partner-filter-params"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; @@ -16,8 +17,11 @@ export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const parsedParams = partnersExportQuerySchema.parse(searchParams); - const { columns, ...filters } = parsedParams; + const params = parsePartnerListQuery( + searchParams, + partnersExportQuerySchema, + ); + const { columns, ...filters } = params; const partnersCount = await getPartnersCount({ ...filters, @@ -25,12 +29,11 @@ export const GET = withWorkspace( programId, }); - // Process the export in the background if the number of partners is greater than MAX_PARTNERS_TO_EXPORT if (partnersCount > MAX_PARTNERS_TO_EXPORT) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/partners`, body: { - ...parsedParams, + ...params, columns: columns.join(","), programId, userId: session.user.id, diff --git a/apps/web/app/(ee)/api/partners/route.ts b/apps/web/app/(ee)/api/partners/route.ts index fc07db3cacd..ce87973b827 100644 --- a/apps/web/app/(ee)/api/partners/route.ts +++ b/apps/web/app/(ee)/api/partners/route.ts @@ -1,5 +1,6 @@ import { createAndEnrollPartner } from "@/lib/api/partners/create-and-enroll-partner"; import { getPartners } from "@/lib/api/partners/get-partners"; +import { parsePartnerListQuery } from "@/lib/api/partners/parse-partner-filter-params"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; @@ -12,50 +13,21 @@ import { getPartnersQuerySchemaExtended, partnerPlatformSchema, } from "@/lib/zod/schemas/partners"; -import { parseFilterValue, toCentsNumber } from "@dub/utils"; +import { toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; -function parsePartnerFilterParams( - searchParams: Record, -) { - const partnerTagIdParsed = parseFilterValue(searchParams.partnerTagId); - const groupIdParsed = parseFilterValue(searchParams.groupId); - const countryParsed = parseFilterValue(searchParams.country); - - return { - partnerTagId: partnerTagIdParsed?.values, - partnerTagIdOperator: partnerTagIdParsed?.sqlOperator, - groupId: groupIdParsed?.values, - groupIdOperator: groupIdParsed?.sqlOperator, - country: countryParsed?.values, - countryOperator: countryParsed?.sqlOperator, - }; -} - // GET /api/partners - get all partners for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const filterOverrides = parsePartnerFilterParams(searchParams); - const paramsToParse = { - ...searchParams, - ...(filterOverrides.partnerTagId && { - partnerTagId: filterOverrides.partnerTagId, - }), - ...(filterOverrides.groupId !== undefined && { - groupId: filterOverrides.groupId, - }), - ...(filterOverrides.country !== undefined && { - country: filterOverrides.country, - }), - }; const { sortBy: sortByWithOldFields, includePartnerPlatforms, ...parsedParams - } = getPartnersQuerySchemaExtended - .extend({ + } = parsePartnerListQuery( + searchParams, + getPartnersQuerySchemaExtended.extend({ // add old fields for backward compatibility sortBy: getPartnersQuerySchemaExtended.shape.sortBy.or( z.enum([ @@ -67,8 +39,8 @@ export const GET = withWorkspace( "totalSales", ]), ), - }) - .parse(paramsToParse); + }), + ); // get the final sortBy field (replace old fields with new fields) const sortBy = @@ -84,12 +56,6 @@ export const GET = withWorkspace( console.time("getPartners"); const partners = await getPartners({ ...parsedParams, - partnerTagId: filterOverrides.partnerTagId ?? parsedParams.partnerTagId, - partnerTagIdOperator: filterOverrides.partnerTagIdOperator, - groupId: filterOverrides.groupId ?? parsedParams.groupId, - groupIdOperator: filterOverrides.groupIdOperator, - country: filterOverrides.country ?? parsedParams.country, - countryOperator: filterOverrides.countryOperator, sortBy, programId, }); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx index 6435bc10d43..1d2dd2d0a00 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx @@ -78,11 +78,20 @@ export function ProgramPartnersApplicationsPageClient() { (key) => !["sortBy", "sortOrder", "page"].includes(key), ); - const { filters, activeFilters, onSelect, onRemove, onRemoveAll } = - usePartnerFilters({ sortBy, sortOrder, status: "pending" }, [ - "groupId", - "country", - ]); + const { + filters, + activeFilters, + onSelect, + onRemove, + onRemoveFilter, + onRemoveAll, + onToggleOperator, + onOpenFilter, + setSearch, + } = usePartnerFilters({ sortBy, sortOrder, status: "pending" }, [ + "groupId", + "country", + ]); const { partnersCount, error: countError } = usePartnersCount({ status: "pending", @@ -463,6 +472,11 @@ export function ProgramPartnersApplicationsPageClient() { activeFilters={activeFilters} onSelect={onSelect} onRemove={onRemove} + onRemoveFilter={onRemoveFilter} + isAdvancedFilter + onOpenFilter={onOpenFilter} + onSearchChange={setSearch} + onSelectedFilterChange={onOpenFilter} />
@@ -497,7 +511,10 @@ export function ProgramPartnersApplicationsPageClient() { activeFilters={activeFilters} onSelect={onSelect} onRemove={onRemove} + onRemoveFilter={onRemoveFilter} onRemoveAll={onRemoveAll} + onToggleOperator={onToggleOperator} + isAdvancedFilter />
)} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx index f2cf6bd814b..c4ceb14860a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx @@ -70,8 +70,17 @@ export function ProgramPartnersRejectedApplicationsPageClient() { const sortBy = searchParams.get("sortBy") || "createdAt"; const sortOrder = searchParams.get("sortOrder") === "asc" ? "asc" : "desc"; - const { filters, activeFilters, onSelect, onRemove, onRemoveAll } = - usePartnerFilters({ sortBy, sortOrder, status: "rejected" }, ["country"]); + const { + filters, + activeFilters, + onSelect, + onRemove, + onRemoveFilter, + onRemoveAll, + onToggleOperator, + onOpenFilter, + setSearch, + } = usePartnerFilters({ sortBy, sortOrder, status: "rejected" }, ["country"]); const { partnersCount, error: countError } = usePartnersCount({ status: "rejected", @@ -401,6 +410,11 @@ export function ProgramPartnersRejectedApplicationsPageClient() { activeFilters={activeFilters} onSelect={onSelect} onRemove={onRemove} + onRemoveFilter={onRemoveFilter} + isAdvancedFilter + onOpenFilter={onOpenFilter} + onSearchChange={setSearch} + onSelectedFilterChange={onOpenFilter} />
)} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx index f9fc936bcd0..99a178b8295 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx @@ -557,6 +557,8 @@ function PartnersFilters({ onRemoveFilter, onRemoveAll, onToggleOperator, + onOpenFilter, + setSearch, } = usePartnerFilters({ sortBy, sortOrder, status }); return ( @@ -569,6 +571,10 @@ function PartnersFilters({ onSelect={onSelect} onRemove={onRemove} onRemoveFilter={onRemoveFilter} + isAdvancedFilter + onOpenFilter={onOpenFilter} + onSearchChange={setSearch} + onSelectedFilterChange={onOpenFilter} /> )} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx index bea99d32319..18123dfb3b3 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx @@ -31,57 +31,19 @@ import { nFormatter, OG_AVATAR_URL, parseFilterValue, - type FilterOperator, - type ParsedFilter, } from "@dub/utils"; import { useCallback, useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; -const SINGLE_VALUE_FILTER_KEYS = ["status", "referredByPartnerId"] as const; -const MULTI_VALUE_FILTER_KEYS = ["partnerTagId", "groupId", "country"] as const; - -function buildMultiValueParam( - parsed: ParsedFilter | undefined, - values: string[], -): string { - return buildFilterValue({ - operator: parsed?.operator ?? (values.length > 1 ? "IS_ONE_OF" : "IS"), - sqlOperator: parsed?.sqlOperator ?? "IN", - values, - }); -} +const CATEGORICAL_FILTER_KEYS = [ + "groupId", + "partnerTagId", + "status", + "country", + "referredByPartnerId", +] as const; -function activeFiltersToSearchParams( - activeFilters: Array< - | { key: string; values: string[]; operator: FilterOperator } - | { key: string; value: string } - >, -): Record { - return Object.fromEntries( - activeFilters.flatMap((f) => { - if ("values" in f && Array.isArray(f.values) && "operator" in f) { - const values = f.values as string[]; - const op: FilterOperator = - (f as { operator?: FilterOperator }).operator ?? - (values.length > 1 ? "IS_ONE_OF" : "IS"); - return [ - [ - f.key, - buildFilterValue({ - operator: op, - sqlOperator: op.includes("NOT") ? "NOT IN" : "IN", - values, - }), - ], - ]; - } - if ("value" in f && f.value != null) { - return [[f.key, f.value]]; - } - return []; - }), - ); -} +type CategoricalFilterKey = (typeof CATEGORICAL_FILTER_KEYS)[number]; const PARTNER_METRIC_RANGE = [ { @@ -166,10 +128,10 @@ export function usePartnerFilters( ], ) { const { searchParamsObj, queryParams } = useRouterStuff(); - const { id: workspaceId, slug } = useWorkspace(); + const { slug } = useWorkspace(); const status = (searchParamsObj.status || extraSearchParams.status || - "approved") as ProgramEnrollmentStatus; + "approved_invited") as ProgramEnrollmentStatus; const cohortParams = useMemo( () => ({ @@ -248,6 +210,7 @@ export function usePartnerFilters( const { referredByPartners } = useReferredByPartnerFilterOptions({ search: selectedFilter === "referredByPartnerId" ? debouncedSearch : "", enabled: enabledFilters.includes("referredByPartnerId"), + status: searchParamsObj.search ? undefined : status, }); const referredByCountMap = useMemo( @@ -296,7 +259,6 @@ export function usePartnerFilters( key: "partnerTagId", icon: Tag, label: "Tag", - multiple: true, shouldFilter: !partnerTagsAsync, options: partnerTags?.map(({ id, name, count, hideDuringSearch }) => ({ @@ -314,7 +276,6 @@ export function usePartnerFilters( key: "status", icon: CircleDotted, label: "Status", - singleSelect: true, options: statusCount ?.filter( @@ -450,40 +411,18 @@ export function usePartnerFilters( ], ); - const partnerTagIdParsed = useMemo( - () => parseFilterValue(searchParamsObj.partnerTagId), - [searchParamsObj.partnerTagId], - ); - const groupIdParsed = useMemo( - () => parseFilterValue(searchParamsObj.groupId), - [searchParamsObj.groupId], - ); - const countryParsed = useMemo( - () => parseFilterValue(searchParamsObj.country), - [searchParamsObj.country], - ); - - const parsedByKey = useMemo( - () => ({ - partnerTagId: partnerTagIdParsed, - groupId: groupIdParsed, - country: countryParsed, - }), - [partnerTagIdParsed, groupIdParsed, countryParsed], - ); - const activeFilters = useMemo(() => { - const multiValueFilters = MULTI_VALUE_FILTER_KEYS.flatMap((key) => { + const categoricalFilters = CATEGORICAL_FILTER_KEYS.flatMap((key) => { if (!enabledFilters.includes(key)) return []; - const parsed = parsedByKey[key]; - if (!parsed) return []; - return [{ key, values: parsed.values, operator: parsed.operator }]; - }); - const singleValueFilters = SINGLE_VALUE_FILTER_KEYS.flatMap((key) => { - if (!enabledFilters.includes(key)) return []; - const value = searchParamsObj[key]; - if (!value) return []; - return [{ key, value }]; + const parsed = parseFilterValue(searchParamsObj[key]); + if (!parsed?.values.length) return []; + return [ + { + key, + values: parsed.values, + operator: parsed.operator, + }, + ]; }); const metricFilters = PARTNER_METRIC_RANGE.filter((m) => enabledFilters.includes(m.filterKey), @@ -509,8 +448,8 @@ export function usePartnerFilters( }, ]; }); - return [...multiValueFilters, ...singleValueFilters, ...metricFilters]; - }, [searchParamsObj, enabledFilters, parsedByKey]); + return [...categoricalFilters, ...metricFilters]; + }, [searchParamsObj, enabledFilters]); const onSelect = useCallback( (key: string, value: unknown) => { @@ -531,23 +470,48 @@ export function usePartnerFilters( return; } - if ( - MULTI_VALUE_FILTER_KEYS.includes( - key as (typeof MULTI_VALUE_FILTER_KEYS)[number], - ) - ) { - const parsed = parsedByKey[key as keyof typeof parsedByKey]; - const currentValues = parsed?.values ?? []; - const next = String(value); - const newValues = currentValues.includes(next) - ? currentValues - : [...currentValues, next]; - const newParam = buildMultiValueParam(parsed, newValues); - return queryParams({ set: { [key]: newParam }, del: "page" }); + if (!CATEGORICAL_FILTER_KEYS.includes(key as CategoricalFilterKey)) { + return; + } + + const currentParam = searchParamsObj[key]; + const parsed = parseFilterValue(currentParam); + const next = String(value); + + if (!currentParam || !parsed) { + return queryParams({ + set: { + [key]: buildFilterValue({ + operator: "IS", + sqlOperator: "IN", + values: [next], + }), + }, + del: "page", + }); + } + + if (parsed.values.includes(next)) { + return; } - return queryParams({ set: { [key]: value as string }, del: "page" }); + + const newValues = [...parsed.values, next]; + return queryParams({ + set: { + [key]: buildFilterValue({ + operator: parsed.operator.includes("NOT") + ? parsed.operator + : newValues.length > 1 + ? "IS_ONE_OF" + : "IS", + sqlOperator: parsed.sqlOperator, + values: newValues, + }), + }, + del: "page", + }); }, - [queryParams, parsedByKey], + [queryParams, searchParamsObj], ); const onRemove = useCallback( @@ -561,38 +525,54 @@ export function usePartnerFilters( } if ( - MULTI_VALUE_FILTER_KEYS.includes( - key as (typeof MULTI_VALUE_FILTER_KEYS)[number], - ) && - value + CATEGORICAL_FILTER_KEYS.includes(key as CategoricalFilterKey) && + value != null ) { - const parsed = parsedByKey[key as keyof typeof parsedByKey]; - const newValues = (parsed?.values ?? []).filter((v) => v !== value); + const currentParam = searchParamsObj[key]; + const parsed = parseFilterValue(currentParam); + if (!parsed) { + return queryParams({ del: [key, "page"] }); + } + const newValues = parsed.values.filter((v) => v !== String(value)); if (newValues.length === 0) { return queryParams({ del: [key, "page"] }); } - const newParam = buildMultiValueParam(parsed, newValues); - return queryParams({ set: { [key]: newParam }, del: "page" }); + return queryParams({ + set: { + [key]: buildFilterValue({ + operator: parsed.operator.includes("NOT") + ? parsed.operator + : newValues.length > 1 + ? "IS_ONE_OF" + : "IS", + sqlOperator: parsed.sqlOperator, + values: newValues, + }), + }, + del: "page", + }); } return queryParams({ del: [key, "page"] }); }, - [queryParams, parsedByKey], + [queryParams, searchParamsObj], ); const onRemoveFilter = useCallback( (key: string) => { - onRemove(key); + const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key); + if (metric) { + return queryParams({ + del: [metric.minParam, metric.maxParam, "page"], + }); + } + return queryParams({ del: [key, "page"] }); }, - [onRemove], + [queryParams], ); const onToggleOperator = useCallback( (key: string) => { - if ( - !MULTI_VALUE_FILTER_KEYS.includes( - key as (typeof MULTI_VALUE_FILTER_KEYS)[number], - ) - ) { + if (!CATEGORICAL_FILTER_KEYS.includes(key as CategoricalFilterKey)) { return; } const raw = searchParamsObj[key]; @@ -607,6 +587,11 @@ export function usePartnerFilters( [queryParams, searchParamsObj], ); + const onOpenFilter = useCallback( + (key: string | null) => setSelectedFilter(key), + [], + ); + const onRemoveAll = useCallback( () => queryParams({ @@ -633,31 +618,6 @@ export function usePartnerFilters( [queryParams], ); - const searchQuery = useMemo(() => { - const acc: Record = { - workspaceId: workspaceId || "", - ...extraSearchParams, - }; - if (searchParamsObj.search) { - acc.search = searchParamsObj.search; - } - for (const f of activeFilters) { - const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === f.key); - if (metric && "value" in f && f.value != null) { - const { min, max } = parseRangeToken(String(f.value)); - if (min != null) { - acc[metric.minParam] = String(min); - } - if (max != null) { - acc[metric.maxParam] = String(max); - } - } else { - Object.assign(acc, activeFiltersToSearchParams([f])); - } - } - return new URLSearchParams(acc).toString(); - }, [activeFilters, searchParamsObj.search, workspaceId, extraSearchParams]); - return { filters, activeFilters, @@ -666,9 +626,8 @@ export function usePartnerFilters( onRemoveFilter, onRemoveAll, onToggleOperator, - setSelectedFilter, + onOpenFilter, setSearch, - searchQuery, }; } @@ -763,32 +722,42 @@ function usePartnerTagFilterOptions({ function useReferredByPartnerFilterOptions({ search, enabled = true, + status, }: { search: string; enabled?: boolean; + status?: ProgramEnrollmentStatus; }) { const { searchParamsObj } = useRouterStuff(); + const activePartnerIds = useMemo(() => { + const parsed = parseFilterValue(searchParamsObj.referredByPartnerId); + return parsed?.values ?? []; + }, [searchParamsObj.referredByPartnerId]); + + const query = { search, ...(status && { status }) }; + const { partners, loading: partnersLoading } = usePartners({ - query: { search }, + query, enabled, }); const { partners: selectedPartners } = usePartners({ query: { - partnerIds: searchParamsObj.referredByPartnerId - ? [searchParamsObj.referredByPartnerId] - : undefined, + ...query, + partnerIds: activePartnerIds.length ? activePartnerIds : undefined, }, - enabled: enabled && !!searchParamsObj.referredByPartnerId, + enabled: enabled && activePartnerIds.length > 0, }); const result = useMemo(() => { if ( partnersLoading || - (searchParamsObj.referredByPartnerId && - ![...(selectedPartners ?? []), ...(partners ?? [])].some( - (p) => p.id === searchParamsObj.referredByPartnerId, + (activePartnerIds.length && + !activePartnerIds.every((id) => + [...(selectedPartners ?? []), ...(partners ?? [])].some( + (p) => p.id === id, + ), )) ) { return null; @@ -800,12 +769,7 @@ function useReferredByPartnerFilterOptions({ ?.filter((sp) => !partners?.some((p) => p.id === sp.id)) ?.map((sp) => ({ ...sp, hideDuringSearch: true })) ?? []), ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]; - }, [ - partnersLoading, - partners, - selectedPartners, - searchParamsObj.referredByPartnerId, - ]); + }, [partnersLoading, partners, selectedPartners, activePartnerIds]); return { referredByPartners: result }; } diff --git a/apps/web/lib/api/partners/get-partners-count.ts b/apps/web/lib/api/partners/get-partners-count.ts index 27d81ab2d95..8db7a36003d 100644 --- a/apps/web/lib/api/partners/get-partners-count.ts +++ b/apps/web/lib/api/partners/get-partners-count.ts @@ -3,18 +3,17 @@ import { prisma } from "@dub/prisma"; import { Prisma, ProgramEnrollmentStatus } from "@dub/prisma/client"; import * as z from "zod/v4"; import { + buildEnrollmentStatusWhere, buildMetricRangeWhere, buildNullableStringListWhere, buildPartnerEmailSearchWhere, buildProgramEnrollmentWhereForList, mergePartnerCountryAndSearchWhere, + type PartnerEnrollmentQueryFilters, } from "./program-enrollment-query"; -type PartnersCountFilters = z.infer & { - programId: string; - partnerTagIdOperator?: "IN" | "NOT IN"; - groupIdOperator?: "IN" | "NOT IN"; - countryOperator?: "IN" | "NOT IN"; +type PartnersCountFilters = PartnerEnrollmentQueryFilters & { + groupBy?: z.infer["groupBy"]; }; export async function getPartnersCount( @@ -35,8 +34,11 @@ export async function getPartnersCount( partnerTagIdOperator = "IN", groupIdOperator = "IN", countryOperator = "IN", + statusOperator = "IN", } = enrollmentFilters; + const statusWhere = buildEnrollmentStatusWhere(status, statusOperator); + const enrollmentScope: Prisma.ProgramEnrollmentWhereInput = { programId, ...(tenantId ? { tenantId } : {}), @@ -104,12 +106,7 @@ export async function getPartnersCount( some: { ...enrollmentScope, ...(groupIdWhere ?? {}), - status: - status === "approved_invited" - ? { - in: ["approved", "invited"], - } - : status, + ...(statusWhere !== undefined ? { status: statusWhere } : {}), ...enrollmentMetricWhere, }, }, @@ -132,6 +129,7 @@ export async function getPartnersCount( where: { ...enrollmentScope, ...(groupIdWhere ?? {}), + ...(statusWhere !== undefined ? { status: statusWhere } : {}), partner: partnerWhereWithCountry, ...enrollmentMetricWhere, }, @@ -143,15 +141,16 @@ export async function getPartnersCount( }, }); - // Find missing statuses - const missingStatuses = Object.values(ProgramEnrollmentStatus).filter( - (status) => !partners.some((p) => p.status === status), - ); + // Only pad missing enum values when no status filter is active + if (statusWhere === undefined) { + const missingStatuses = Object.values(ProgramEnrollmentStatus).filter( + (status) => !partners.some((p) => p.status === status), + ); - // Add missing statuses with count 0 - missingStatuses.forEach((status) => { - partners.push({ _count: 0, status: status }); - }); + missingStatuses.forEach((status) => { + partners.push({ _count: 0, status: status }); + }); + } return partners as T; } @@ -162,12 +161,7 @@ export async function getPartnersCount( where: { ...enrollmentScope, partner: partnerWhereWithCountry, - status: - status === "approved_invited" - ? { - in: ["approved", "invited"], - } - : status, + ...(statusWhere !== undefined ? { status: statusWhere } : {}), ...enrollmentMetricWhere, }, _count: true, @@ -215,12 +209,7 @@ export async function getPartnersCount( programEnrollment: { ...enrollmentScope, ...(groupIdWhere ?? {}), - status: - status === "approved_invited" - ? { - in: ["approved", "invited"], - } - : status, + ...(statusWhere !== undefined ? { status: statusWhere } : {}), partner: partnerWhereWithCountry, ...enrollmentMetricWhere, }, diff --git a/apps/web/lib/api/partners/get-partners.ts b/apps/web/lib/api/partners/get-partners.ts index 64d3ecf8fe5..5d10fa0acc5 100644 --- a/apps/web/lib/api/partners/get-partners.ts +++ b/apps/web/lib/api/partners/get-partners.ts @@ -2,13 +2,17 @@ import { getPartnersQuerySchemaExtended } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { toCentsNumber } from "@dub/utils"; import * as z from "zod/v4"; -import { buildProgramEnrollmentWhereForList } from "./program-enrollment-query"; +import { + buildProgramEnrollmentWhereForList, + type PartnerEnrollmentQueryFilters, +} from "./program-enrollment-query"; -type PartnerFilters = z.infer & { - programId: string; - partnerTagIdOperator?: "IN" | "NOT IN"; - groupIdOperator?: "IN" | "NOT IN"; - countryOperator?: "IN" | "NOT IN"; +type PartnerFilters = PartnerEnrollmentQueryFilters & { + sortBy: z.infer["sortBy"]; + sortOrder: z.infer["sortOrder"]; + page?: number; + pageSize: z.infer["pageSize"]; + includePartnerPlatforms?: boolean; }; export async function getPartners(filters: PartnerFilters) { diff --git a/apps/web/lib/api/partners/parse-partner-filter-params.ts b/apps/web/lib/api/partners/parse-partner-filter-params.ts new file mode 100644 index 00000000000..a836fe1d312 --- /dev/null +++ b/apps/web/lib/api/partners/parse-partner-filter-params.ts @@ -0,0 +1,131 @@ +import { DubApiError } from "@/lib/api/errors"; +import { parseFilterValue } from "@dub/utils"; +import type * as z from "zod/v4"; + +const MAX_VALUES = 50; +const APPLICATION_STATUSES = new Set(["pending", "rejected"]); + +export type ParsedPartnerFilterOverrides = { + partnerTagId?: string[]; + partnerTagIdOperator?: "IN" | "NOT IN"; + groupId?: string[]; + groupIdOperator?: "IN" | "NOT IN"; + country?: string[]; + countryOperator?: "IN" | "NOT IN"; + status?: string[]; + statusOperator?: "IN" | "NOT IN"; + referredByPartnerId?: string[]; + referredByPartnerIdOperator?: "IN" | "NOT IN"; +}; + +function parseCategoricalFilter( + key: string, + value: string | undefined, +): { values: string[]; sqlOperator: "IN" | "NOT IN" } | undefined { + const parsed = parseFilterValue(value); + if (!parsed) return undefined; + + if (parsed.values.length > MAX_VALUES) { + throw new DubApiError({ + code: "bad_request", + message: `Filter "${key}" accepts at most ${MAX_VALUES} values.`, + }); + } + + return parsed; +} + +function validateStatusFilter(values: string[]): void { + if (values.length === 0) return; + + const hasApplication = values.some((v) => APPLICATION_STATUSES.has(v)); + const hasNonApplication = values.some( + (v) => !APPLICATION_STATUSES.has(v) && v !== "approved_invited", + ); + + if (values.includes("pending") && values.includes("rejected")) { + throw new DubApiError({ + code: "bad_request", + message: + 'Status filter cannot combine "pending" and "rejected" in one request.', + }); + } + + if (hasApplication && hasNonApplication) { + throw new DubApiError({ + code: "bad_request", + message: + "Status filter cannot combine application statuses (pending, rejected) with other partner statuses.", + }); + } + + if (values.includes("approved_invited") && values.length > 1) { + throw new DubApiError({ + code: "bad_request", + message: + 'Status filter cannot combine "approved_invited" with other status values.', + }); + } +} + +export function parsePartnerFilterParams( + searchParams: Record, +): ParsedPartnerFilterOverrides { + const partnerTagIdParsed = parseCategoricalFilter( + "partnerTagId", + searchParams.partnerTagId, + ); + const groupIdParsed = parseCategoricalFilter("groupId", searchParams.groupId); + const countryParsed = parseCategoricalFilter("country", searchParams.country); + const statusParsed = parseCategoricalFilter("status", searchParams.status); + const referredByPartnerIdParsed = parseCategoricalFilter( + "referredByPartnerId", + searchParams.referredByPartnerId, + ); + + if (statusParsed?.values.length) { + validateStatusFilter(statusParsed.values); + } + + return { + partnerTagId: partnerTagIdParsed?.values, + partnerTagIdOperator: partnerTagIdParsed?.sqlOperator, + groupId: groupIdParsed?.values, + groupIdOperator: groupIdParsed?.sqlOperator, + country: countryParsed?.values, + countryOperator: countryParsed?.sqlOperator, + status: statusParsed?.values, + statusOperator: statusParsed?.sqlOperator, + referredByPartnerId: referredByPartnerIdParsed?.values, + referredByPartnerIdOperator: referredByPartnerIdParsed?.sqlOperator, + }; +} + +/** Parse URL filters once, run Zod, attach multi-value arrays + SQL operators. */ +export function parsePartnerListQuery>( + searchParams: Record, + schema: z.ZodType, +): T & ParsedPartnerFilterOverrides { + const filters = parsePartnerFilterParams(searchParams); + const parsed = schema.parse({ + ...searchParams, + ...(filters.partnerTagId && { partnerTagId: filters.partnerTagId }), + ...(filters.groupId !== undefined && { groupId: filters.groupId }), + ...(filters.country !== undefined && { country: filters.country }), + ...(filters.status !== undefined && { status: filters.status }), + ...(filters.referredByPartnerId !== undefined && { + referredByPartnerId: filters.referredByPartnerId, + }), + }); + + return { + ...parsed, + ...filters, + partnerTagId: filters.partnerTagId ?? parsed.partnerTagId, + groupId: filters.groupId ?? parsed.groupId, + country: filters.country ?? parsed.country, + status: filters.status ?? parsed.status, + referredByPartnerId: + filters.referredByPartnerId ?? parsed.referredByPartnerId, + } as T & ParsedPartnerFilterOverrides; +} diff --git a/apps/web/lib/api/partners/program-enrollment-query.ts b/apps/web/lib/api/partners/program-enrollment-query.ts index 7063af691ab..1c7e81abae4 100644 --- a/apps/web/lib/api/partners/program-enrollment-query.ts +++ b/apps/web/lib/api/partners/program-enrollment-query.ts @@ -1,6 +1,6 @@ import { getPartnersQuerySchemaExtended } from "@/lib/zod/schemas/partners"; import { sanitizeFullTextSearch } from "@dub/prisma"; -import { Prisma } from "@dub/prisma/client"; +import { Prisma, ProgramEnrollmentStatus } from "@dub/prisma/client"; import * as z from "zod/v4"; /** @@ -34,12 +34,25 @@ export function buildPartnerEmailSearchWhere({ export type PartnerEnrollmentQueryFilters = Omit< z.infer, - "sortBy" | "sortOrder" | "page" | "pageSize" | "includePartnerPlatforms" + | "sortBy" + | "sortOrder" + | "page" + | "pageSize" + | "includePartnerPlatforms" + | "status" + | "referredByPartnerId" > & { programId: string; + status?: + | z.infer["status"] + | string + | string[]; + referredByPartnerId?: string | string[]; partnerTagIdOperator?: "IN" | "NOT IN"; groupIdOperator?: "IN" | "NOT IN"; countryOperator?: "IN" | "NOT IN"; + statusOperator?: "IN" | "NOT IN"; + referredByPartnerIdOperator?: "IN" | "NOT IN"; }; function normalizeBounds( @@ -180,6 +193,67 @@ export function buildNullableStringListWhere( } as Prisma.ProgramEnrollmentWhereInput | Prisma.PartnerWhereInput; } +export function buildEnrollmentStatusWhere( + status: string | string[] | undefined, + statusOperator: "IN" | "NOT IN" = "IN", +): Prisma.ProgramEnrollmentWhereInput["status"] | undefined { + if (status === undefined) { + return undefined; + } + + const list = normalizeStringList(status); + if (!list) { + return undefined; + } + + if (list.length === 1 && list[0] === "approved_invited") { + return { in: ["approved", "invited"] }; + } + + const exclude = statusOperator === "NOT IN"; + const statuses = list as ProgramEnrollmentStatus[]; + + if (!exclude) { + return statuses.length === 1 ? statuses[0] : { in: statuses }; + } + + return statuses.length === 1 ? { not: statuses[0] } : { notIn: statuses }; +} + +export function buildReferredByPartnerIdWhere( + referredByPartnerId: string | string[] | undefined, + referredByPartnerIdOperator: "IN" | "NOT IN" = "IN", +): Prisma.ProgramEnrollmentWhereInput | undefined { + const list = normalizeStringList(referredByPartnerId); + if (!list) { + return undefined; + } + + const exclude = referredByPartnerIdOperator === "NOT IN"; + const inOrEquals = list.length === 1 ? list[0]! : { in: list }; + const negation = list.length === 1 ? { not: list[0]! } : { notIn: list }; + + if (!exclude) { + return { + applicationEvent: { + referredByPartnerId: inOrEquals, + }, + }; + } + + return { + OR: [ + { applicationEvent: { is: null } }, + { applicationEvent: { referredByPartnerId: null } }, + { + applicationEvent: { + referredByPartnerId: negation, + }, + }, + ], + }; +} + export function mergePartnerCountryAndSearchWhere( countryWhere: Prisma.PartnerWhereInput | undefined, searchWhere: Prisma.PartnerWhereInput, @@ -215,9 +289,16 @@ export function buildProgramEnrollmentWhereForList( partnerTagIdOperator = "IN", groupIdOperator = "IN", countryOperator = "IN", + statusOperator = "IN", + referredByPartnerIdOperator = "IN", } = filters; const metricWhere = buildMetricRangeWhere(filters); + const statusWhere = buildEnrollmentStatusWhere(status, statusOperator); + const referredByWhere = buildReferredByPartnerIdWhere( + referredByPartnerId, + referredByPartnerIdOperator, + ); const partnerTagIdNotIn = partnerTagIdOperator === "NOT IN"; const groupIdNotIn = groupIdOperator === "NOT IN"; @@ -268,19 +349,10 @@ export function buildProgramEnrollmentWhereForList( in: partnerIds, }, }), - status: - status === "approved_invited" - ? { - in: ["approved", "invited"], - } - : status, + ...(statusWhere !== undefined ? { status: statusWhere } : {}), ...(groupIdWhere ?? {}), ...(hasPartnerWhere ? { partner: partnerWhere } : {}), - ...(referredByPartnerId && { - applicationEvent: { - referredByPartnerId, - }, - }), + ...(referredByWhere ?? {}), ...metricWhere, }; } diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 93f33c98008..a1a008ff848 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -194,8 +194,11 @@ export const getPartnersQuerySchema = z export const getPartnersQuerySchemaExtended = getPartnersQuerySchema.extend({ status: z - .enum(ProgramEnrollmentStatus) - .or(z.enum(["approved_invited"])) + .union([ + z.enum(ProgramEnrollmentStatus), + z.enum(["approved_invited"]), + z.array(z.enum(ProgramEnrollmentStatus).or(z.enum(["approved_invited"]))), + ]) .optional(), partnerIds: z .union([z.string(), z.array(z.string())]) @@ -207,7 +210,7 @@ export const getPartnersQuerySchemaExtended = getPartnersQuerySchema.extend({ .optional(), groupId: z.union([z.string(), z.array(z.string())]).optional(), country: z.union([z.string(), z.array(z.string())]).optional(), - referredByPartnerId: z.string().optional(), + referredByPartnerId: z.union([z.string(), z.array(z.string())]).optional(), includePartnerPlatforms: booleanQuerySchema.optional(), // metric range query fields (TODO: Add to public API once we finalize the syntax) totalClicksMin: z.coerce