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 ebc8e07306a..8bcde47d589 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 @@ -4,17 +4,96 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerStatusBadges } from "@/ui/partners/partner-status-badges"; import { ProgramEnrollmentStatus } from "@dub/prisma/client"; -import { useRouterStuff } from "@dub/ui"; -import { CircleDotted, FlagWavy, Users6 } from "@dub/ui/icons"; -import { cn, COUNTRIES, nFormatter } from "@dub/utils"; +import { encodeRangeToken, parseRangeToken, useRouterStuff } from "@dub/ui"; +import { + CircleDotted, + CursorRays, + FlagWavy, + InvoiceDollar, + MarketingTarget, + MoneyBills2, + UserPlus, + Users6, +} from "@dub/ui/icons"; +import { cn, COUNTRIES, currencyFormatter, nFormatter } from "@dub/utils"; import { useMemo } from "react"; +const PARTNER_METRIC_RANGE = [ + { + filterKey: "totalClicks", + minParam: "totalClicksMin", + maxParam: "totalClicksMax", + metric: "totalClicks" as const, + label: "Clicks", + icon: CursorRays, + }, + { + filterKey: "totalLeads", + minParam: "totalLeadsMin", + maxParam: "totalLeadsMax", + metric: "totalLeads" as const, + label: "Leads", + icon: UserPlus, + }, + { + filterKey: "totalConversions", + minParam: "totalConversionsMin", + maxParam: "totalConversionsMax", + metric: "totalConversions" as const, + label: "Conversions", + icon: MarketingTarget, + }, + { + filterKey: "totalSaleAmount", + minParam: "totalSaleAmountMin", + maxParam: "totalSaleAmountMax", + metric: "totalSaleAmount" as const, + label: "Revenue", + icon: InvoiceDollar, + formatRangeBound: (n: number) => currencyFormatter(n), + parseRangeInput: (raw: string) => { + const n = Number.parseFloat(raw.replace(/[^0-9.-]/g, "")); + if (!Number.isFinite(n)) { + return Number.NaN; + } + return Math.round(n * 100); + }, + }, + { + filterKey: "totalCommissions", + minParam: "totalCommissionsMin", + maxParam: "totalCommissionsMax", + metric: "totalCommissions" as const, + label: "Commissions", + icon: MoneyBills2, + formatRangeBound: (n: number) => currencyFormatter(n), + parseRangeInput: (raw: string) => { + const n = Number.parseFloat(raw.replace(/[^0-9.-]/g, "")); + if (!Number.isFinite(n)) { + return Number.NaN; + } + return Math.round(n * 100); + }, + }, +] as const; + +export type PartnerFilterKey = + | "groupId" + | "status" + | "country" + | (typeof PARTNER_METRIC_RANGE)[number]["filterKey"]; + export function usePartnerFilters( extraSearchParams: Record, - enabledFilters: ("groupId" | "status" | "country")[] = [ + enabledFilters: PartnerFilterKey[] = [ "groupId", "status", "country", + "totalClicks", + "totalLeads", + "totalConversions", + "totalSaleAmount", + "totalCommissions", ], ) { const { searchParamsObj, queryParams } = useRouterStuff(); @@ -25,6 +104,15 @@ export function usePartnerFilters( const { groups } = useGroups(); + const cohortParams = useMemo( + () => ({ + ...(searchParamsObj.groupId && { groupId: searchParamsObj.groupId }), + ...(searchParamsObj.country && { country: searchParamsObj.country }), + ...(searchParamsObj.search && { search: searchParamsObj.search }), + }), + [searchParamsObj.groupId, searchParamsObj.country, searchParamsObj.search], + ); + const { partnersCount: countriesCount } = usePartnersCount< | { country: string; @@ -34,6 +122,7 @@ export function usePartnerFilters( >({ groupBy: "country", status, + ...cohortParams, enabled: enabledFilters.includes("country"), }); @@ -44,7 +133,9 @@ export function usePartnerFilters( }[] | undefined >({ - groupBy: "status", // here we include all statuses to get the groupBy count + groupBy: "status", + status, + ...cohortParams, enabled: enabledFilters.includes("status"), }); @@ -57,6 +148,7 @@ export function usePartnerFilters( >({ groupBy: "groupId", status, + ...cohortParams, enabled: enabledFilters.includes("groupId"), }); @@ -128,14 +220,17 @@ export function usePartnerFilters( key: "country", icon: FlagWavy, label: "Location", - getOptionIcon: (value) => ( + separatorAfter: PARTNER_METRIC_RANGE.some((m) => + enabledFilters.includes(m.filterKey), + ), + getOptionIcon: (value: string) => ( {value} ), - getOptionLabel: (value) => COUNTRIES[value], + getOptionLabel: (value: string) => COUNTRIES[value], options: countriesCount ?.filter(({ country }) => COUNTRIES[country]) @@ -147,62 +242,187 @@ export function usePartnerFilters( }, ] : []), + ...PARTNER_METRIC_RANGE.filter((m) => + enabledFilters.includes(m.filterKey), + ).map((m) => { + const formatRangeBound = + "formatRangeBound" in m && m.formatRangeBound + ? m.formatRangeBound + : (n: number) => nFormatter(n, { full: true }); + const parseRangeInput = + "parseRangeInput" in m && m.parseRangeInput + ? m.parseRangeInput + : (raw: string) => { + const n = Number.parseInt(raw.replace(/[^\d-]/g, ""), 10); + return Number.isFinite(n) ? n : Number.NaN; + }; + return { + key: m.filterKey, + icon: m.icon, + label: m.label, + type: "range" as const, + options: null, + ...(m.metric === "totalCommissions" + ? { + rangeDisplayScale: 100, + rangeNumberStep: 0.01, + } + : {}), + formatRangeBound, + parseRangeInput, + formatRangePillLabel: (token: string) => { + const { min, max } = parseRangeToken(token); + if (min != null && max != null) { + return `${formatRangeBound(min)} – ${formatRangeBound(max)}`; + } + if (min != null) { + return `${formatRangeBound(min)} – No max`; + } + if (max != null) { + return `No min – ${formatRangeBound(max)}`; + } + return token; + }, + }; + }), ], - [groupsCount, groups, statusCount, countriesCount], + [groupsCount, groups, statusCount, countriesCount, slug, enabledFilters], ); const activeFilters = useMemo(() => { - const { groupId, status, country } = searchParamsObj; + const { groupId, status: statusParam, country } = searchParamsObj; return [ ...(enabledFilters.includes("groupId") && groupId ? [{ key: "groupId", value: groupId }] : []), - ...(enabledFilters.includes("status") && status - ? [{ key: "status", value: status }] + ...(enabledFilters.includes("status") && statusParam + ? [{ key: "status", value: statusParam }] : []), ...(enabledFilters.includes("country") && country ? [{ key: "country", value: country }] : []), + ...PARTNER_METRIC_RANGE.filter((m) => + enabledFilters.includes(m.filterKey), + ).flatMap((m) => { + const minRaw = searchParamsObj[m.minParam]; + const maxRaw = searchParamsObj[m.maxParam]; + const min = + minRaw !== undefined && minRaw !== "" ? Number(minRaw) : undefined; + const max = + maxRaw !== undefined && maxRaw !== "" ? Number(maxRaw) : undefined; + const minOk = min !== undefined && Number.isFinite(min); + const maxOk = max !== undefined && Number.isFinite(max); + if (!minOk && !maxOk) { + return []; + } + return [ + { + key: m.filterKey, + value: encodeRangeToken( + minOk ? min : undefined, + maxOk ? max : undefined, + ), + }, + ]; + }), ]; - }, [searchParamsObj]); + }, [searchParamsObj, enabledFilters]); + + const onSelect = (key: string, value: unknown) => { + const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key); + if (metric) { + const { min, max } = parseRangeToken(String(value)); + queryParams({ + set: { + ...(min != null ? { [metric.minParam]: String(min) } : {}), + ...(max != null ? { [metric.maxParam]: String(max) } : {}), + }, + del: [ + ...(min == null ? [metric.minParam] : []), + ...(max == null ? [metric.maxParam] : []), + "page", + ], + }); + return; + } - const onSelect = (key: string, value: any) => queryParams({ - set: { - [key]: value, - }, + set: { [key]: value as string }, del: "page", }); + }; + + const onRemove = (key: string, _value?: unknown) => { + const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key); + if (metric) { + queryParams({ + del: [metric.minParam, metric.maxParam, "page"], + }); + return; + } - const onRemove = (key: string) => queryParams({ del: [key, "page"], }); + }; + + const onRemoveFilter = (key: string) => { + onRemove(key); + }; const onRemoveAll = () => queryParams({ - del: ["status", "country", "groupId", "search"], + del: [ + "status", + "country", + "groupId", + "search", + "totalClicksMin", + "totalClicksMax", + "totalLeadsMin", + "totalLeadsMax", + "totalConversionsMin", + "totalConversionsMax", + "totalSaleAmountMin", + "totalSaleAmountMax", + "totalCommissionsMin", + "totalCommissionsMax", + "page", + ], }); - const searchQuery = useMemo( - () => - new URLSearchParams({ - ...Object.fromEntries( - activeFilters.map(({ key, value }) => [key, value]), - ), - ...(searchParamsObj.search && { search: searchParamsObj.search }), - workspaceId: workspaceId || "", - ...extraSearchParams, - }).toString(), - [activeFilters, workspaceId, extraSearchParams], - ); + const searchQuery = useMemo(() => { + const acc: Record = { + workspaceId: workspaceId || "", + ...extraSearchParams, + }; + if (searchParamsObj.search) { + acc.search = searchParamsObj.search; + } + for (const { key, value } of activeFilters) { + const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key); + if (metric) { + const { min, max } = parseRangeToken(String(value)); + if (min != null) { + acc[metric.minParam] = String(min); + } + if (max != null) { + acc[metric.maxParam] = String(max); + } + } else { + acc[key] = String(value); + } + } + return new URLSearchParams(acc).toString(); + }, [activeFilters, workspaceId, extraSearchParams, searchParamsObj.search]); return { filters, activeFilters, onSelect, onRemove, + onRemoveFilter, onRemoveAll, searchQuery, }; diff --git a/apps/web/lib/api/partners/get-partners-count.ts b/apps/web/lib/api/partners/get-partners-count.ts index 0924f6c6f2b..6d7e2c7489d 100644 --- a/apps/web/lib/api/partners/get-partners-count.ts +++ b/apps/web/lib/api/partners/get-partners-count.ts @@ -1,7 +1,12 @@ import { partnersCountQuerySchema } from "@/lib/zod/schemas/partners"; -import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; +import { prisma } from "@dub/prisma"; import { Prisma, ProgramEnrollmentStatus } from "@dub/prisma/client"; import * as z from "zod/v4"; +import { + buildMetricRangeWhere, + buildPartnerEmailSearchWhere, + buildProgramEnrollmentWhereForList, +} from "./program-enrollment-query"; type PartnersCountFilters = z.infer & { programId: string; @@ -10,34 +15,21 @@ type PartnersCountFilters = z.infer & { export async function getPartnersCount( filters: PartnersCountFilters, ): Promise { - const { - groupBy, - status, - country, - search, - email, - partnerIds, - groupId, - programId, - } = filters; + const { groupBy, programId, ...enrollmentFilters } = filters; + const enrollmentBase = { ...enrollmentFilters, programId }; + + const { status, country, search, email, partnerIds, groupId } = + enrollmentFilters; const commonWhere: Prisma.PartnerWhereInput = { - ...(email - ? { email } - : search - ? search.includes("@") - ? { email: search } - : { - email: { search: sanitizeFullTextSearch(search) }, - name: { search: sanitizeFullTextSearch(search) }, - companyName: { search: sanitizeFullTextSearch(search) }, - } - : {}), + ...buildPartnerEmailSearchWhere({ email, search }), ...(partnerIds && { id: { in: partnerIds }, }), }; + const enrollmentMetricWhere = buildMetricRangeWhere(enrollmentBase); + // Get partner count by country if (groupBy === "country") { const partners = await prisma.partner.groupBy({ @@ -50,6 +42,7 @@ export async function getPartnersCount( groupId, }), status, + ...enrollmentMetricWhere, }, }, ...commonWhere, @@ -80,6 +73,7 @@ export async function getPartnersCount( }), ...commonWhere, }, + ...enrollmentMetricWhere, }, _count: true, orderBy: { @@ -96,7 +90,7 @@ export async function getPartnersCount( // Add missing statuses with count 0 missingStatuses.forEach((status) => { - partners.push({ _count: 0, status }); + partners.push({ _count: 0, status: status }); }); return partners as T; @@ -115,6 +109,7 @@ export async function getPartnersCount( ...commonWhere, }, status, + ...enrollmentMetricWhere, }, _count: true, orderBy: { @@ -129,19 +124,7 @@ export async function getPartnersCount( // Get absolute count of partners const count = await prisma.programEnrollment.count({ - where: { - programId, - status, - ...(groupId && { - groupId, - }), - partner: { - ...(country && { - country, - }), - ...commonWhere, - }, - }, + where: buildProgramEnrollmentWhereForList(enrollmentBase), }); return count as T; diff --git a/apps/web/lib/api/partners/get-partners.ts b/apps/web/lib/api/partners/get-partners.ts index 04cd6bfb004..7d2cb7484e2 100644 --- a/apps/web/lib/api/partners/get-partners.ts +++ b/apps/web/lib/api/partners/get-partners.ts @@ -1,7 +1,8 @@ import { getPartnersQuerySchemaExtended } from "@/lib/zod/schemas/partners"; -import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; +import { prisma } from "@dub/prisma"; import { toCentsNumber } from "@dub/utils"; import * as z from "zod/v4"; +import { buildProgramEnrollmentWhereForList } from "./program-enrollment-query"; type PartnerFilters = z.infer & { programId: string; @@ -9,50 +10,20 @@ type PartnerFilters = z.infer & { export async function getPartners(filters: PartnerFilters) { const { - status, - country, - search, - email, - tenantId, - partnerIds, page = 1, pageSize, sortBy, sortOrder, programId, - groupId, + includePartnerPlatforms: _includePartnerPlatforms, + ...enrollmentRest } = filters; const partners = await prisma.programEnrollment.findMany({ - where: { - tenantId, + where: buildProgramEnrollmentWhereForList({ + ...enrollmentRest, programId, - ...(partnerIds && { - partnerId: { - in: partnerIds, - }, - }), - status, - groupId, - ...(country || search || email - ? { - partner: { - country, - ...(email - ? { email } - : search - ? search.includes("@") - ? { email: search } - : { - email: { search: sanitizeFullTextSearch(search) }, - name: { search: sanitizeFullTextSearch(search) }, - companyName: { search: sanitizeFullTextSearch(search) }, - } - : {}), - }, - } - : {}), - }, + }), include: { partner: { include: { diff --git a/apps/web/lib/api/partners/program-enrollment-query.ts b/apps/web/lib/api/partners/program-enrollment-query.ts new file mode 100644 index 00000000000..ef78eb51b2c --- /dev/null +++ b/apps/web/lib/api/partners/program-enrollment-query.ts @@ -0,0 +1,168 @@ +import { getPartnersQuerySchemaExtended } from "@/lib/zod/schemas/partners"; +import { sanitizeFullTextSearch } from "@dub/prisma"; +import { Prisma } from "@dub/prisma/client"; +import * as z from "zod/v4"; + +/** + * Email / search filters on `Partner` (exact email, or full-text on email/name/company). + */ +export function buildPartnerEmailSearchWhere({ + email, + search, +}: { + email?: string | null; + search?: string | null; +}): Prisma.PartnerWhereInput { + if (email) { + return { email }; + } + if (search) { + if (search.includes("@")) { + return { email: search }; + } + const q = sanitizeFullTextSearch(search); + return { + OR: [ + { email: { search: q } }, + { name: { search: q } }, + { companyName: { search: q } }, + ], + }; + } + return {}; +} + +export type PartnerEnrollmentQueryFilters = Omit< + z.infer, + "sortBy" | "sortOrder" | "page" | "pageSize" | "includePartnerPlatforms" +> & { programId: string }; + +function normalizeBounds( + min?: number | null, + max?: number | null, +): { min?: number; max?: number } { + if (min == null && max == null) { + return {}; + } + if (min != null && max != null && min > max) { + return { min: max, max: min }; + } + return { ...(min != null ? { min } : {}), ...(max != null ? { max } : {}) }; +} + +/** Metric range filters for program enrollment list/count queries. */ +export function buildMetricRangeWhere( + filters: PartnerEnrollmentQueryFilters, +): Prisma.ProgramEnrollmentWhereInput { + const and: Prisma.ProgramEnrollmentWhereInput[] = []; + + { + const b = normalizeBounds(filters.totalClicksMin, filters.totalClicksMax); + if (b.min != null || b.max != null) { + and.push({ + totalClicks: { + ...(b.min != null && { gte: b.min }), + ...(b.max != null && { lte: b.max }), + }, + }); + } + } + + { + const b = normalizeBounds(filters.totalLeadsMin, filters.totalLeadsMax); + if (b.min != null || b.max != null) { + and.push({ + totalLeads: { + ...(b.min != null && { gte: b.min }), + ...(b.max != null && { lte: b.max }), + }, + }); + } + } + + { + const b = normalizeBounds( + filters.totalConversionsMin, + filters.totalConversionsMax, + ); + if (b.min != null || b.max != null) { + and.push({ + totalConversions: { + ...(b.min != null && { gte: b.min }), + ...(b.max != null && { lte: b.max }), + }, + }); + } + } + + { + const b = normalizeBounds( + filters.totalSaleAmountMin, + filters.totalSaleAmountMax, + ); + if (b.min != null || b.max != null) { + and.push({ + totalSaleAmount: { + ...(b.min != null && { gte: b.min }), + ...(b.max != null && { lte: b.max }), + }, + }); + } + } + + { + const b = normalizeBounds( + filters.totalCommissionsMin, + filters.totalCommissionsMax, + ); + if (b.min != null || b.max != null) { + and.push({ + totalCommissions: { + ...(b.min != null && { gte: BigInt(Math.trunc(b.min)) }), + ...(b.max != null && { lte: BigInt(Math.trunc(b.max)) }), + }, + }); + } + } + + return and.length ? { AND: and } : {}; +} + +/** Matches GET /api/partners enrollment filter shape + metric ranges. */ +export function buildProgramEnrollmentWhereForList( + filters: PartnerEnrollmentQueryFilters, +): Prisma.ProgramEnrollmentWhereInput { + const { + programId, + status, + groupId, + country, + tenantId, + partnerIds, + search, + email, + } = filters; + + const metricWhere = buildMetricRangeWhere(filters); + + return { + tenantId, + programId, + ...(partnerIds && { + partnerId: { + in: partnerIds, + }, + }), + status, + groupId, + ...(country || search || email + ? { + partner: { + country, + ...buildPartnerEmailSearchWhere({ email, search }), + }, + } + : {}), + ...metricWhere, + }; +} diff --git a/apps/web/lib/middleware/utils/bots-list.ts b/apps/web/lib/middleware/utils/bots-list.ts index 2baf6d78062..f08f07ac178 100644 --- a/apps/web/lib/middleware/utils/bots-list.ts +++ b/apps/web/lib/middleware/utils/bots-list.ts @@ -16,6 +16,9 @@ export const UA_BOTS = [ "bluesky", // Bluesky crawler "facebookexternalhit", // Facebook crawler "meta-externalagent", // Meta external agent + "meta-externalads", // Meta external ads + "meta-externalfetcher", // Meta external fetcher + "meta-webindexer", // Meta web indexer "thirdLandingPageFeInfra", // TikTok preloader (https://ads.tiktok.com/help/article/preloading-web-content) "WhatsApp", // WhatsApp crawler "google", // Google crawler diff --git a/apps/web/lib/openapi/embed-tokens/create-referrals-embed-token.ts b/apps/web/lib/openapi/embed-tokens/create-referrals-embed-token.ts index c02c4b99c71..cc4c6044973 100644 --- a/apps/web/lib/openapi/embed-tokens/create-referrals-embed-token.ts +++ b/apps/web/lib/openapi/embed-tokens/create-referrals-embed-token.ts @@ -9,7 +9,8 @@ export const createReferralsEmbedToken: ZodOpenApiOperationObject = { operationId: "createReferralsEmbedToken", "x-speakeasy-name-override": "referrals", summary: "Create a referrals embed token", - description: "Create a referrals embed token for the given partner/tenant.", + description: + "Create a referrals embed token for the given partner/tenant. The endpoint first attempts to locate an existing enrollment using the provided tenantId. If no enrollment is found, it resolves the partner by email and creates a new enrollment as needed. This results in an upsert-style flow that guarantees a valid enrollment and returns a usable embed token.", requestBody: { content: { "application/json": { diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 928b1f82029..3b6fd97f8dc 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -120,6 +120,11 @@ export const exportApplicationsColumnsDefault = [ export const getPartnersQuerySchema = z .object({ + groupId: z + .string() + .optional() + .describe("A filter on the list based on the partner's `groupId` field.") + .meta({ example: "grp_123" }), status: z .enum(ProgramEnrollmentStatus) .optional() @@ -185,8 +190,68 @@ export const getPartnersQuerySchemaExtended = getPartnersQuerySchema.extend({ .union([z.string(), z.array(z.string())]) .transform((v) => (Array.isArray(v) ? v : v.split(","))) .optional(), - groupId: z.string().optional(), includePartnerPlatforms: booleanQuerySchema.optional(), + // metric range query fields (TODO: Add to public API once we finalize the syntax) + totalClicksMin: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Minimum total clicks (inclusive)."), + totalClicksMax: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Maximum total clicks (inclusive)."), + totalLeadsMin: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Minimum total leads (inclusive)."), + totalLeadsMax: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Maximum total leads (inclusive)."), + totalConversionsMin: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Minimum total conversions (inclusive)."), + totalConversionsMax: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Maximum total conversions (inclusive)."), + totalSaleAmountMin: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Minimum total sale amount (inclusive) in USD cents."), + totalSaleAmountMax: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Maximum total sale amount (inclusive) in USD cents."), + totalCommissionsMin: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Minimum total commissions (inclusive) in USD cents."), + totalCommissionsMax: z.coerce + .number() + .int() + .nonnegative() + .optional() + .describe("Maximum total commissions (inclusive) in USD cents."), }); export const partnersExportQuerySchema = getPartnersQuerySchemaExtended diff --git a/apps/web/scripts/misc/disable-workspace-links.ts b/apps/web/scripts/misc/disable-workspace-links.ts index 8eb87bf2d0b..d9c1a5bd73b 100644 --- a/apps/web/scripts/misc/disable-workspace-links.ts +++ b/apps/web/scripts/misc/disable-workspace-links.ts @@ -4,7 +4,7 @@ import "dotenv-flow/config"; import { linkCache } from "../../lib/api/links/cache"; import { updateConfig } from "../../lib/edge-config"; -// script to get the top google ads campaign ids in fraud events +// script to disable all links for a workspace async function main() { const project = await prisma.project.findUniqueOrThrow({ where: { diff --git a/apps/web/scripts/misc/restore-workspace-access.ts b/apps/web/scripts/misc/restore-workspace-access.ts new file mode 100644 index 00000000000..40f6e2cc3cb --- /dev/null +++ b/apps/web/scripts/misc/restore-workspace-access.ts @@ -0,0 +1,73 @@ +import { prisma } from "@dub/prisma"; +import { LEGAL_USER_ID } from "@dub/utils"; +import "dotenv-flow/config"; +import { linkCache } from "../../lib/api/links/cache"; + +// script to restore access to a workspace for a user +async function main() { + const project = await prisma.project.findUniqueOrThrow({ + where: { + slug: "xxx", + }, + }); + + const user = await prisma.user.findUniqueOrThrow({ + where: { + email: project.name, + }, + }); + + const linksToRestore = await prisma.link.findMany({ + where: { + projectId: project.id, + disabledAt: { + not: null, + }, + }, + }); + + if (linksToRestore.length > 0) { + const restoredLinks = await prisma.link.updateMany({ + where: { + id: { + in: linksToRestore.map((link) => link.id), + }, + }, + data: { + disabledAt: null, + }, + }); + + console.log(`Restored ${restoredLinks.count} links`); + + const res = await linkCache.expireMany(linksToRestore); + console.log(res); + } + + await prisma.projectUsers.update({ + where: { + userId_projectId: { + userId: LEGAL_USER_ID, + projectId: project.id, + }, + }, + data: { + userId: user.id, + }, + }); + + await prisma.project.update({ + where: { + id: project.id, + }, + data: { + name: project.slug, + }, + }); + + console.log( + `Restored access to project ${project.slug} for user ${user.email}`, + ); +} + +main(); diff --git a/apps/web/scripts/misc/transfer-links.ts b/apps/web/scripts/misc/transfer-links.ts new file mode 100644 index 00000000000..0ab502e9500 --- /dev/null +++ b/apps/web/scripts/misc/transfer-links.ts @@ -0,0 +1,89 @@ +import { prisma } from "@dub/prisma"; +import "dotenv-flow/config"; +import { linkCache } from "../../lib/api/links/cache"; +import { includeTags } from "../../lib/api/links/include-tags"; +import { recordLink } from "../../lib/tinybird"; + +// script to transfer links from one workspace to another +async function main() { + const oldWorkspaceId = "ws_xxx"; + const newWorkspaceId = "ws_xxx"; + + const updatedDomains = await prisma.domain.updateMany({ + where: { + projectId: oldWorkspaceId, + }, + data: { + projectId: newWorkspaceId, + }, + }); + + console.log(`Updated ${updatedDomains.count} domains`); + + const updatedRegisteredDomains = await prisma.registeredDomain.updateMany({ + where: { + projectId: oldWorkspaceId, + }, + data: { + projectId: newWorkspaceId, + }, + }); + console.log(`Updated ${updatedRegisteredDomains.count} registered domains`); + + const updatedTags = await prisma.tag.updateMany({ + where: { + projectId: oldWorkspaceId, + }, + data: { + projectId: newWorkspaceId, + }, + }); + + console.log(`Updated ${updatedTags.count} tags`); + + const linksToUpdate = await prisma.link.findMany({ + where: { + projectId: oldWorkspaceId, + }, + }); + + if (linksToUpdate.length > 0) { + const updatedLinks = await prisma.link.updateMany({ + where: { + id: { + in: linksToUpdate.map((link) => link.id), + }, + }, + data: { + projectId: newWorkspaceId, + folderId: null, + disabledAt: null, + }, + }); + + console.log(`Updated ${updatedLinks.count} links`); + + const finalLinks = await prisma.link.findMany({ + where: { + id: { + in: linksToUpdate.map((link) => link.id), + }, + }, + include: includeTags, + }); + + const redisRes = await linkCache.expireMany(finalLinks); + + // set the link with the old workspace ID to be deleted in Tinybird + const tbRes1 = await recordLink(linksToUpdate, { deleted: true }); + + // set the link with the new workspace ID to be created in Tinybird + const tbRes2 = await recordLink(finalLinks); + + console.log("redisRes", redisRes); + console.log("tbRes1", tbRes1); + console.log("tbRes2", tbRes2); + } +} + +main(); diff --git a/packages/ui/src/filter/filter-list.tsx b/packages/ui/src/filter/filter-list.tsx index 7da56c93b5c..70a8233e190 100644 --- a/packages/ui/src/filter/filter-list.tsx +++ b/packages/ui/src/filter/filter-list.tsx @@ -8,12 +8,14 @@ import { AnimatedSizeContainer } from "../animated-size-container"; import { useKeyboardShortcut } from "../hooks"; import { Check } from "../icons"; import { Popover } from "../popover"; +import { FilterRangePanel } from "./filter-range-panel"; import { ActiveFilterInput, Filter, FilterOperator, FilterOption, normalizeActiveFilter, + parseRangeToken, } from "./types"; type FilterListProps = { @@ -64,7 +66,11 @@ export function FilterList({
Clear Filters @@ -287,6 +299,7 @@ function OperatorFilterPill({ const [initialSelectedValues, setInitialSelectedValues] = useState< Set >(new Set()); + const [rangeEditOpen, setRangeEditOpen] = useState(false); const openValueDropdown = useCallback(() => { setInitialSelectedValues(new Set(values)); @@ -307,6 +320,125 @@ function OperatorFilterPill({ [filterKey, values, onSelect, onRemove, isAdvancedFilter, filter.multiple], ); + if (filter.type === "range") { + const token = String(values[0] ?? "|"); + const fmt = + filter.formatRangeBound ?? ((n: number) => String(Math.trunc(n))); + const { min, max } = parseRangeToken(token); + const rangeFullyApplied = min != null && max != null; + const rangeHasAppliedValue = min != null || max != null; + const rangeLabel = + filter.formatRangePillLabel?.(token) ?? + (min != null && max != null + ? `${fmt(min)} – ${fmt(max)}` + : min != null + ? `${fmt(min)} – No max` + : max != null + ? `No min – ${fmt(max)}` + : token); + + return ( + +
+ + {isReactNode(filter.icon) ? ( + filter.icon + ) : ( + + )} + + + + {filter.label} + +
+ +
+ is +
+ + { + if (rangeFullyApplied) { + e.preventDefault(); + setRangeEditOpen(false); + } + }} + content={ + + setRangeEditOpen(false)} + onClear={ + rangeHasAppliedValue + ? () => + onRemoveFilter + ? onRemoveFilter(filterKey) + : onRemove(filterKey, token) + : undefined + } + onCloseOuter={ + rangeFullyApplied ? () => setRangeEditOpen(false) : undefined + } + onApply={(t) => { + if (t === "|") { + if (onRemoveFilter) { + onRemoveFilter(filterKey); + } else { + onRemove(filterKey, token); + } + } else { + onSelect?.(filterKey, t); + } + }} + /> + + } + > + + + + +
+ ); + } + return ( { @@ -350,7 +485,10 @@ function OperatorFilterPill({ @@ -446,6 +589,8 @@ function OperatorFilterPill({ key={option.value} className={cn( "flex cursor-pointer items-center gap-3 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm", + "transition-[background-color] duration-100 ease-out motion-reduce:transition-none", + "active:scale-[0.99] motion-reduce:active:scale-100", "data-[selected=true]:bg-neutral-100", )} onSelect={() => { @@ -524,7 +669,8 @@ function OperatorFilterPill({ disabled={filter.options?.length === 0} className={cn( "flex items-center", - filter.options?.length && "transition-colors hover:bg-neutral-50", + filter.options?.length && + "transition-[background-color,transform] duration-150 ease-out hover:bg-neutral-50 active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 [@media(hover:none)]:hover:bg-transparent", )} > {!filter.options ? ( @@ -539,7 +685,13 @@ function OperatorFilterPill({ + + {label} +
+ + {onClear && ( + + )} + + ); +} + +function RangeEndControl({ + bound, + value, + unboundedLabel, + parseInput, + displayScale, + step, + onCommit, + inputRef, + onEmptyMinBackspace, + onFocusNextField, + onFocusPreviousField, + onEscapeCloseFilter, +}: { + bound: RangeBound; + value: number | undefined; + unboundedLabel: string; + parseInput: (raw: string) => number; + displayScale: number; + step: number | string; + onCommit: (next: number | undefined) => void; + inputRef?: Ref; + onEmptyMinBackspace?: () => void; + onFocusNextField?: () => void; + onFocusPreviousField?: () => void; + onEscapeCloseFilter?: () => void; +}) { + const [draft, setDraft] = useState(() => storageToDraft(value, displayScale)); + + const setInputRef = useCallback( + (node: HTMLInputElement | null) => { + if (!inputRef) { + return; + } + if (typeof inputRef === "function") { + inputRef(node); + } else { + (inputRef as MutableRefObject).current = node; + } + }, + [inputRef], + ); + + useEffect(() => { + setDraft(storageToDraft(value, displayScale)); + }, [value, displayScale]); + + const commitDraft = useCallback(() => { + const raw = draft.trim(); + if (raw === "") { + onCommit(undefined); + return; + } + const n = parseInput(raw); + if (!Number.isFinite(n)) { + setDraft(storageToDraft(value, displayScale)); + return; + } + onCommit(n); + }, [draft, onCommit, parseInput, value, displayScale]); + + const parsedStep = typeof step === "string" ? Number.parseFloat(step) : step; + const stepNum = + Number.isFinite(parsedStep) && parsedStep > 0 ? parsedStep : 1; + + return ( +
+ { + setDraft(sanitizeNumericDraft(e.target.value, displayScale)); + }} + onKeyDown={(e) => { + e.stopPropagation(); + + if (e.key === "Backspace" && onEmptyMinBackspace && draft === "") { + e.preventDefault(); + onEmptyMinBackspace(); + return; + } + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + const raw = draft.trim(); + const displayVal = + raw === "" + ? 0 + : displayScale === 1 + ? Number.parseInt(raw, 10) + : Number.parseFloat(raw); + if (!Number.isFinite(displayVal)) { + return; + } + const delta = e.key === "ArrowUp" ? stepNum : -stepNum; + const nextDisplay = Math.max(0, displayVal + delta); + const nextDraft = + displayScale === 1 + ? String(Math.round(nextDisplay)) + : String(Number(nextDisplay.toFixed(2))); + setDraft(nextDraft); + return; + } + + const inputEl = e.currentTarget; + const rangeStart = inputEl.selectionStart; + const rangeEnd = inputEl.selectionEnd; + const hasCaret = + rangeStart !== null && rangeEnd !== null && rangeStart === rangeEnd; + + // Only move between fields when we know caret position (`?? 0` would fake "at start" when APIs return null). + if (hasCaret) { + if ( + bound === "min" && + e.key === "ArrowRight" && + onFocusNextField && + rangeStart === draft.length + ) { + e.preventDefault(); + onFocusNextField(); + return; + } + + if ( + bound === "max" && + (e.key === "ArrowLeft" || e.key === "Backspace") && + onFocusPreviousField && + rangeStart === 0 + ) { + e.preventDefault(); + onFocusPreviousField(); + return; + } + } + + if (e.key === "Escape" && onEscapeCloseFilter) { + e.preventDefault(); + onEscapeCloseFilter(); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + commitDraft(); + } + }} + onBlur={() => { + commitDraft(); + }} + /> +
+ ); +} + +function FilterRangeContent({ + filter, + activeToken, + onApply, + onNavigateBack, + onCloseFilter, +}: { + filter: Filter; + activeToken: string | undefined; + onApply: (token: string) => void; + onNavigateBack: () => void; + /** When the applied range has both min and max, Escape closes the outer filter instead of going back. */ + onCloseFilter?: () => void; +}) { + const { min, max } = parseRangeToken(activeToken); + const rangeFullyApplied = min != null && max != null; + const parse = + filter.parseRangeInput ?? + ((raw: string) => { + const n = Number.parseInt(raw.replace(/[^\d-]/g, ""), 10); + return Number.isFinite(n) ? n : Number.NaN; + }); + + const displayScale = filter.rangeDisplayScale ?? 1; + const step = filter.rangeNumberStep ?? (displayScale === 1 ? 1 : 0.01); + + const commitBoth = useCallback( + (nextMin?: number, nextMax?: number) => { + const { min: a, max: b } = normalizeRangeBounds(nextMin, nextMax); + onApply(encodeRangeToken(a, b)); + }, + [onApply], + ); + + const commitMin = useCallback( + (next: number | undefined) => { + const { min: a, max: b } = normalizeRangeBounds(next, max); + commitBoth(a, b); + }, + [max, commitBoth], + ); + + const commitMax = useCallback( + (next: number | undefined) => { + const { min: a, max: b } = normalizeRangeBounds(min, next); + commitBoth(a, b); + }, + [min, commitBoth], + ); + + const minInputRef = useRef(null); + const maxInputRef = useRef(null); + + const focusMinAtEnd = useCallback(() => { + const el = minInputRef.current; + if (!el) { + return; + } + el.focus(); + const len = el.value.length; + queueMicrotask(() => { + el.setSelectionRange(len, len); + }); + }, []); + + const focusMaxAtStart = useCallback(() => { + const el = maxInputRef.current; + if (!el) { + return; + } + el.focus(); + queueMicrotask(() => { + el.setSelectionRange(0, 0); + }); + }, []); + + useEffect(() => { + const id = requestAnimationFrame(() => { + minInputRef.current?.focus(); + }); + return () => cancelAnimationFrame(id); + }, []); + + return ( +
{ + if (e.key !== "Backspace" && e.key !== "Delete") { + return; + } + const t = e.target as HTMLElement; + if (t.closest("input, textarea, [contenteditable=true]")) { + return; + } + e.preventDefault(); + onNavigateBack(); + }} + > +
+
+ +
+ + to + +
+ +
+
+
+ ); +} + +export type FilterRangePanelProps = { + filter: Filter; + activeToken: string | undefined; + onApply: (token: string) => void; + onBack: () => void; + onClear?: () => void; + onCloseOuter?: () => void; + scrollRef?: Ref; +}; + +export function FilterRangePanel({ + filter, + activeToken, + onApply, + onBack, + onClear, + onCloseOuter, + scrollRef, +}: FilterRangePanelProps) { + return ( + <> + + + + + + ); +} diff --git a/packages/ui/src/filter/filter-scroll.tsx b/packages/ui/src/filter/filter-scroll.tsx new file mode 100644 index 00000000000..51911ca8a78 --- /dev/null +++ b/packages/ui/src/filter/filter-scroll.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { + forwardRef, + MutableRefObject, + PropsWithChildren, + RefCallback, + useCallback, + useRef, +} from "react"; +import { useScrollProgress } from "../hooks/use-scroll-progress"; + +export const FilterScroll = forwardRef< + HTMLDivElement | null, + PropsWithChildren +>(({ children }, forwardedRef) => { + const ref = useRef(null); + const { scrollProgress, updateScrollProgress } = useScrollProgress(ref); + + const setRef: RefCallback = useCallback( + (node) => { + ref.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + (forwardedRef as MutableRefObject).current = + node; + } + }, + [forwardedRef], + ); + + return ( + <> +
+ {children} +
+
+ + ); +}); + +FilterScroll.displayName = "FilterScroll"; diff --git a/packages/ui/src/filter/filter-select.tsx b/packages/ui/src/filter/filter-select.tsx index 67a0007729d..7a279230385 100644 --- a/packages/ui/src/filter/filter-select.tsx +++ b/packages/ui/src/filter/filter-select.tsx @@ -5,24 +5,25 @@ import { Fragment, PropsWithChildren, ReactNode, - forwardRef, isValidElement, useCallback, useEffect, - useImperativeHandle, + useMemo, useRef, useState, } from "react"; import { AnimatedSizeContainer } from "../animated-size-container"; import { useKeyboardShortcut, useMediaQuery } from "../hooks"; -import { useScrollProgress } from "../hooks/use-scroll-progress"; import { Check, LoadingSpinner, Magic } from "../icons"; import { Popover } from "../popover"; +import { FilterRangePanel } from "./filter-range-panel"; +import { FilterScroll } from "./filter-scroll"; import { ActiveFilterInput, Filter, FilterOption, normalizeActiveFilter, + parseRangeToken, } from "./types"; type FilterSelectProps = { @@ -32,6 +33,8 @@ type FilterSelectProps = { value: FilterOption["value"] | FilterOption["value"][], ) => void; onRemove: (key: string, value: FilterOption["value"]) => void; + /** Clears an entire filter (e.g. numeric range with two URL params). */ + onRemoveFilter?: (key: string) => void; onOpenFilter?: (key: string) => void; onSearchChange?: (search: string) => void; onSelectedFilterChange?: (key: string | null) => void; @@ -47,6 +50,7 @@ export function FilterSelect({ filters, onSelect, onRemove, + onRemoveFilter, onOpenFilter, onSearchChange, onSelectedFilterChange, @@ -96,6 +100,25 @@ export function FilterSelect({ ? filters.find(({ key }) => key === selectedFilterKey) : null; + const activeRangeTokenForSelected = useMemo(() => { + if (!selectedFilter || selectedFilter.type !== "range" || !activeFilters) { + return undefined; + } + const raw = activeFilters.find((f) => f.key === selectedFilter.key); + if (!raw) { + return undefined; + } + return normalizeActiveFilter(raw).values[0] as string | undefined; + }, [activeFilters, selectedFilter]); + + const rangeFilterHasAppliedValue = useMemo(() => { + if (!selectedFilter || selectedFilter.type !== "range") { + return false; + } + const { min, max } = parseRangeToken(activeRangeTokenForSelected); + return min != null || max != null; + }, [selectedFilter, activeRangeTokenForSelected]); + const openFilter = useCallback( (key: Filter["key"]) => { // Maintain dimensions for loading options @@ -178,12 +201,22 @@ export function FilterSelect({ openPopover={isOpen} setOpenPopover={setIsOpen} onEscapeKeyDown={(e) => { + if (selectedFilter?.type === "range") { + const { min, max } = parseRangeToken(activeRangeTokenForSelected); + if (min != null && max != null) { + e.preventDefault(); + setIsOpen(false); + return; + } + } if (selectedFilterKey) { - console.log("Escape key pressed in Popover"); e.preventDefault(); e.stopPropagation(); goBackOrClose(); + return; } + e.preventDefault(); + setIsOpen(false); }} content={ - -
- { - if ( - e.key === "Escape" || - (e.key === "Backspace" && !search) - ) { + {selectedFilter?.type === "range" ? ( + reset()} + onClear={ + rangeFilterHasAppliedValue && selectedFilterKey + ? () => + onRemoveFilter + ? onRemoveFilter(selectedFilterKey) + : onRemove( + selectedFilterKey, + activeRangeTokenForSelected ?? "|", + ) + : undefined + } + onCloseOuter={() => setIsOpen(false)} + onApply={(token) => { + if (token === "|") { + onRemoveFilter + ? onRemoveFilter(selectedFilter.key) + : onRemove( + selectedFilter.key, + activeRangeTokenForSelected ?? "|", + ); + } else { + onSelect(selectedFilter.key, token); + } + }} + /> + ) : ( + +
+ { + if ( + e.key === "Escape" || + ((e.key === "Backspace" || e.key === "Delete") && !search) + ) { + e.preventDefault(); + e.stopPropagation(); + goBackOrClose(); + } + }} + onEmptySubmit={(e) => { e.preventDefault(); e.stopPropagation(); - goBackOrClose(); - } - }} - onEmptySubmit={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (askAI) { - onSelect( - "ai", - // Prepend search with selected filter label for more context - selectedFilter - ? `${selectedFilter.label} ${search}` - : search, - ); - setIsOpen(false); - } else selectOption(search); - }} - /> - {!selectedFilter && ( - - F - - )} -
- - + {!selectedFilter && ( + + F + )} - > - {!selectedFilter - ? // Top-level filters - filters - .filter((filter) => !filter.hideInFilterDropdown) - .map((filter) => ( - - openFilter(filter.key)} - /> - {filter.separatorAfter && ( - - )} - - )) - : // Filter options - selectedFilter.options - ?.filter((option) => !search || !option.hideDuringSearch) - ?.map((option) => { - const isSingleSelect = - selectedFilter?.singleSelect || - (!isAdvancedFilter && !selectedFilter?.multiple); - const isSelected = isOptionSelected(option.value); - - return ( - +
+ + + {!selectedFilter + ? // Top-level filters + filters + .filter((filter) => !filter.hideInFilterDropdown) + .map((filter) => ( + + openFilter(filter.key)} + /> + {filter.separatorAfter && ( + + )} + + )) + : // Filter options + selectedFilter.options + ?.filter( + (option) => !search || !option.hideDuringSearch, + ) + ?.map((option) => { + const isSingleSelect = + selectedFilter?.singleSelect || + (!isAdvancedFilter && !selectedFilter?.multiple); + const isSelected = isOptionSelected(option.value); + + return ( + + ) : ( + option.right + ) ) : ( option.right ) - ) : ( - option.right - ) - } - onSelect={() => selectOption(option.value)} - /> - ); - }) ?? ( - // Filter options loading state - -
- -
-
- )} - - {/* Only render CommandEmpty if not loading */} - {(!selectedFilter || selectedFilter.options) && ( - selectOption(search)} - askAI={askAI} - > - {emptyState - ? isEmptyStateObject(emptyState) - ? emptyState?.[selectedFilterKey ?? "default"] ?? - "No matching options" - : emptyState - : "No matching options"} - - )} -
-
-
+ } + onSelect={() => selectOption(option.value)} + /> + ); + }) ?? ( + // Filter options loading state + +
+ +
+
+ )} + + {/* Only render CommandEmpty if not loading */} + {(!selectedFilter || selectedFilter.options) && ( + selectOption(search)} + askAI={askAI} + > + {emptyState + ? isEmptyStateObject(emptyState) + ? emptyState?.[selectedFilterKey ?? "default"] ?? + "No matching options" + : emptyState + : "No matching options"} + + )} + + + + )}
} >
) : ( )} @@ -384,32 +455,6 @@ const CommandInput = ( ); }; -const FilterScroll = forwardRef( - ({ children }: PropsWithChildren, forwardedRef) => { - const ref = useRef(null); - useImperativeHandle(forwardedRef, () => ref.current); - - const { scrollProgress, updateScrollProgress } = useScrollProgress(ref); - - return ( - <> -
- {children} -
- {/* Bottom scroll fade */} -
- - ); - }, -); - function FilterButton({ filter, option, @@ -440,6 +485,8 @@ function FilterButton({ string; + /** Parse typed input into storage units. Return NaN if invalid. */ + parseRangeInput?: (raw: string) => number; + /** + * For `type: "range"`: divide stored values by this for the number input (e.g. `100` when storage is cents). + * Defaults to `1` (storage shown as-is). + */ + rangeDisplayScale?: number; + /** + * `step` on the min/max number inputs. Defaults to `1` when `rangeDisplayScale` is 1, else `0.01`. + */ + rangeNumberStep?: number; + /** Full pill label for active range token (used by `Filter.List`). */ + formatRangePillLabel?: (token: string) => string; hideInFilterDropdown?: boolean; // Hide in Filter.Select dropdown shouldFilter?: boolean; // Disable filtering for this filter separatorAfter?: boolean; // Add a separator after the filter in Filter.Select dropdown @@ -102,3 +119,31 @@ export function normalizeActiveFilter(filter: ActiveFilterInput): ActiveFilter { values: [], }; } + +export function parseRangeToken(token: string | undefined | null): { + min?: number; + max?: number; +} { + if (token == null || token === "|") { + return {}; + } + const [a, b] = token.split("|"); + const min = a === "" ? undefined : Number(a); + const max = b === "" ? undefined : Number(b); + return { + ...(Number.isFinite(min) ? { min } : {}), + ...(Number.isFinite(max) ? { max } : {}), + }; +} + +export function encodeRangeToken( + min?: number | null, + max?: number | null, +): string { + const l = min == null || !Number.isFinite(min) ? "" : String(Math.trunc(min)); + const r = max == null || !Number.isFinite(max) ? "" : String(Math.trunc(max)); + if (!l && !r) { + return "|"; + } + return `${l}|${r}`; +}