Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fd64edb
multi-select ui filter for link analytics
pepeladeira Feb 1, 2026
36dfe29
initial support for advanced filters in link
pepeladeira Feb 2, 2026
f77562e
test: e2e for advanced filters
pepeladeira Feb 2, 2026
5b37146
review advanced analytic schema description
pepeladeira Feb 2, 2026
6dbf7b1
return with stripe instance as it was
pepeladeira Feb 2, 2026
b4ff07c
feat: ensure multi select filter for other attrs
pepeladeira Feb 6, 2026
0cf46ed
Merge branch 'main' into feat/advanced-filters
pepeladeira Feb 6, 2026
1230beb
feat: apply isMultiple again and add filters in Events page
pepeladeira Feb 7, 2026
a0db5f6
feat: apply isMultiple again and add filters in Events page
pepeladeira Feb 7, 2026
f0ac778
feat(advanced-filters): ensure filter component is working for every …
pepeladeira Feb 7, 2026
a2e03b0
test(advanced-filters): fix and create new tests
pepeladeira Feb 7, 2026
cbd9e11
feat(advanced-filters): prevent filter-list from closing after select…
pepeladeira Feb 7, 2026
24638cc
test(events): get-events tests suite
pepeladeira Feb 7, 2026
c27ffe5
feat(advanced-filter): analytics pipe helpers
pepeladeira Feb 7, 2026
35627a2
Merge pull request #1 from pepeladeira/feat/advanced-filters
pepeladeira Feb 7, 2026
e2ef1a8
fix: remove repetitive code
pepeladeira Feb 7, 2026
fa4f077
fix(advanced-filter): remove null checks before referencing stripe
pepeladeira Feb 8, 2026
80b4274
fix(advanced-fiters): Double folderId filter causes contradictory WHE…
pepeladeira Feb 8, 2026
e1027b0
fix(advanced-filters): onRemoveFilter('link') won't clear the link fi…
pepeladeira Feb 8, 2026
00684ac
fix(advanced-filters): Add typeof guard for regionForPipe to match th…
pepeladeira Feb 8, 2026
82b2473
fix(advanced-filters): newParam could be undefined when parsed is und…
pepeladeira Feb 8, 2026
47e11b6
fix(advanced-filters): Address static analysis warning on forEach cal…
pepeladeira Feb 8, 2026
d938d52
fix(advanced-filters): Edge case: { key, values, operator: undefined …
pepeladeira Feb 8, 2026
4b0e1b7
fix(advanced-filters): add field-based JSON filter handling in sale_e…
pepeladeira Feb 8, 2026
6f23007
fix(advanced-filters): add field-based JSON filter handling in sale_e…
pepeladeira Feb 8, 2026
7926796
fix(advanced-filters): onToggleOperator preserve pagination state.
pepeladeira Feb 8, 2026
8326b1a
fix(advanced-filters): pluralize os
pepeladeira Feb 8, 2026
e989305
fix(advanced-filters): appropriate extraction functions for numeric m…
pepeladeira Feb 8, 2026
f03501e
fix(advanced-filters): add field-based JSON filter handling in sale_e…
pepeladeira Feb 8, 2026
d7f39c4
fix(advanced-filters): ensure keyboar toggleValue checkbox for filter…
pepeladeira Feb 8, 2026
08d73a5
fix(advanced-filters): mismatch prevents root filter from rendering s…
pepeladeira Feb 8, 2026
7d45f87
Merge branch 'main' into main
steven-tey Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/app/(ee)/api/admin/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { getAnalytics } from "@/lib/analytics/get-analytics";
import { withAdmin } from "@/lib/auth";
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics";
import { NextResponse } from "next/server";

// GET /api/admin/analytics – get analytics for admin
export const GET = withAdmin(async ({ searchParams }) => {
const parsedParams = analyticsQuerySchema.parse(searchParams);
const parsedParams = parseAnalyticsQuery(searchParams);

const response = await getAnalytics(parsedParams);

Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/(ee)/api/admin/events/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { getEvents } from "@/lib/analytics/get-events";
import { withAdmin } from "@/lib/auth";
import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
import { parseEventsQuery } from "@/lib/zod/schemas/analytics";
import { NextResponse } from "next/server";

// GET /api/admin/events – get events for admin
export const GET = withAdmin(async ({ searchParams }) => {
const parsedParams = eventsQuerySchema.parse(searchParams);
const parsedParams = parseEventsQuery(searchParams);

const response = await getEvents(parsedParams);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(ee)/api/cron/usage/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const updateUsage = async () => {
event: "clicks",
groupBy: "top_links",
interval: "30d",
root: false,
root: { values: ["false"], operator: "IS", sqlOperator: "IN" },
});

const topFive = topLinks.slice(0, 5);
Expand Down
18 changes: 13 additions & 5 deletions apps/web/app/(ee)/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks";
import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan";
import { withWorkspace } from "@/lib/auth";
import { verifyFolderAccess } from "@/lib/folder/permissions";
import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
import { parseEventsQuery } from "@/lib/zod/schemas/analytics";
import { Link } from "@dub/prisma/client";
import { NextResponse } from "next/server";

Expand All @@ -15,7 +15,7 @@ export const GET = withWorkspace(
async ({ searchParams, workspace, session }) => {
throwIfClicksUsageExceeded(workspace);

const parsedParams = eventsQuerySchema.parse(searchParams);
const parsedParams = parseEventsQuery(searchParams);

let {
event,
Expand All @@ -24,11 +24,19 @@ export const GET = withWorkspace(
end,
linkId,
externalId,
domain,
domain: domainFilter,
key,
folderId,
folderId: folderIdFilter,
} = parsedParams;

// Extract string values for specific link/folder lookup
const domain = domainFilter && typeof domainFilter === 'object' && 'values' in domainFilter
? domainFilter.values[0]
: undefined;
const folderId = folderIdFilter && typeof folderIdFilter === 'object' && 'values' in folderIdFilter
? folderIdFilter.values[0]
: undefined;

let link: Link | null = null;

if (domain) {
Expand Down Expand Up @@ -78,7 +86,7 @@ export const GET = withWorkspace(
...(link && { linkId: link.id }),
workspaceId: workspace.id,
folderIds,
folderId: folderId || "",
folderId: folderIdFilter || undefined,
});
console.timeEnd("getEvents");

Expand Down
14 changes: 11 additions & 3 deletions apps/web/app/api/analytics/dashboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-r
import { exceededLimitError } from "@/lib/exceeded-limit-error";
import { PlanProps } from "@/lib/types";
import { redis } from "@/lib/upstash";
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics";
import { prisma } from "@dub/prisma";
import { DUB_DEMO_LINKS, DUB_WORKSPACE_ID, getSearchParams } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
Expand All @@ -16,9 +16,17 @@ export const dynamic = "force-dynamic";
export const GET = async (req: Request) => {
try {
const searchParams = getSearchParams(req.url);
const parsedParams = analyticsQuerySchema.parse(searchParams);
const parsedParams = parseAnalyticsQuery(searchParams);

const { domain, key, folderId, interval, start, end } = parsedParams;
const { domain: domainFilter, key, folderId: folderIdFilter, interval, start, end } = parsedParams;

// Extract string values for specific link/folder lookup
const domain = domainFilter && typeof domainFilter === 'object' && 'values' in domainFilter
? domainFilter.values[0]
: undefined;
const folderId = folderIdFilter && typeof folderIdFilter === 'object' && 'values' in folderIdFilter
? folderIdFilter.values[0]
: undefined;

if ((!domain || !key) && !folderId) {
throw new DubApiError({
Expand Down
16 changes: 12 additions & 4 deletions apps/web/app/api/analytics/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks";
import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan";
import { withWorkspace } from "@/lib/auth";
import { verifyFolderAccess } from "@/lib/folder/permissions";
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics";
import { Link } from "@dub/prisma/client";
import JSZip from "jszip";

Expand All @@ -17,11 +17,19 @@ export const GET = withWorkspace(
async ({ searchParams, workspace, session }) => {
throwIfClicksUsageExceeded(workspace);

const parsedParams = analyticsQuerySchema.parse(searchParams);
const parsedParams = parseAnalyticsQuery(searchParams);

const { interval, start, end, linkId, externalId, domain, key, folderId } =
const { interval, start, end, linkId, externalId, domain: domainFilter, key, folderId: folderIdFilter } =
parsedParams;

// Extract string values for specific link/folder lookup
const domain = domainFilter && typeof domainFilter === 'object' && 'values' in domainFilter
? domainFilter.values[0]
: undefined;
const folderId = folderIdFilter && typeof folderIdFilter === 'object' && 'values' in folderIdFilter
? folderIdFilter.values[0]
: undefined;

let link: Link | null = null;

if (domain) {
Expand Down Expand Up @@ -80,7 +88,7 @@ export const GET = withWorkspace(
...(link && { linkId: link.id }),
groupBy: endpoint,
folderIds,
folderId: folderId || "",
folderId: folderIdFilter || undefined,
});

if (!response || response.length === 0) return;
Expand Down
17 changes: 13 additions & 4 deletions apps/web/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { withWorkspace } from "@/lib/auth";
import { verifyFolderAccess } from "@/lib/folder/permissions";
import {
analyticsPathParamsSchema,
analyticsQuerySchema,
parseAnalyticsQuery,
} from "@/lib/zod/schemas/analytics";
import { Link } from "@dub/prisma/client";
import { NextResponse } from "next/server";
Expand All @@ -32,7 +32,7 @@ export const GET = withWorkspace(
oldEvent = undefined;
}

const parsedParams = analyticsQuerySchema.parse(searchParams);
const parsedParams = parseAnalyticsQuery(searchParams);

let {
event,
Expand All @@ -42,12 +42,21 @@ export const GET = withWorkspace(
end,
linkId,
externalId,
domain,
domain: domainFilter,
key,
folderId,
folderId: folderIdFilter,
programId,
} = parsedParams;

// Extract string values for specific link/folder lookup
// When domain+key is provided, it's for getting a specific link (not filtering)
const domain = domainFilter && typeof domainFilter === 'object' && 'values' in domainFilter
? domainFilter.values[0]
: undefined;
const folderId = folderIdFilter && typeof folderIdFilter === 'object' && 'values' in folderIdFilter
? folderIdFilter.values[0]
: undefined;

let link: Link | null = null;

event = oldEvent || event;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function WorkspaceLinks() {
activeFilters,
onSelect,
onRemove,
onRemoveFilter,
onRemoveAll,
setSearch,
setSelectedFilter,
Expand Down Expand Up @@ -250,6 +251,7 @@ function WorkspaceLinks() {
activeFilters={activeFilters}
onSelect={onSelect}
onRemove={onRemove}
onRemoveFilter={onRemoveFilter}
onRemoveAll={onRemoveAll}
/>
</PageWidthWrapper>
Expand Down
47 changes: 47 additions & 0 deletions apps/web/lib/analytics/build-advanced-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ParsedFilter, type SQLOperator } from "@dub/utils";

export interface AdvancedFilter {
field: string;
operator: SQLOperator;
values: string[];
}

const SUPPORTED_FIELDS = [
"country",
"city",
"continent",
"device",
"browser",
"os",
"referer",
"refererUrl",
"url",
"trigger",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"saleType",
] as const;

type SupportedField = (typeof SUPPORTED_FIELDS)[number];

export function buildAdvancedFilters(
params: Partial<Record<SupportedField, ParsedFilter | undefined>>,
): AdvancedFilter[] {
const filters: AdvancedFilter[] = [];

for (const field of SUPPORTED_FIELDS) {
const parsed = params[field];
if (!parsed) continue;

filters.push({
field,
operator: parsed.sqlOperator,
values: parsed.values,
});
}

return filters;
}
74 changes: 74 additions & 0 deletions apps/web/lib/analytics/filter-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ParsedFilter } from "@dub/utils";

/**
* Prepare trigger and region filters for Tinybird pipes.
* Handles backward compatibility for qr parameter and region splitting.
*/
export function prepareFiltersForPipe(params: {
qr?: boolean;
trigger?: ParsedFilter;
region?: string | ParsedFilter;
country?: ParsedFilter;
}) {
// Handle qr backward compatibility
let triggerForPipe = params.trigger;
if (params.qr && !params.trigger) {
triggerForPipe = {
operator: "IS" as const,
sqlOperator: "IN" as const,
values: ["qr"],
};
}

// Handle region split (format: "US-CA")
let countryForPipe = params.country;
let regionForPipe = params.region;
if (params.region && typeof params.region === "string") {
const split = params.region.split("-");
countryForPipe = {
operator: "IS" as const,
sqlOperator: "IN" as const,
values: [split[0]],
};
regionForPipe = split[1];
}

return { triggerForPipe, countryForPipe, regionForPipe };
}

/**
* Extract workspace link filters (domain, tagIds, folderId, root) into
* separate values and operators for Tinybird.
*
* These filters are applied on the workspace_links node in Tinybird,
* so they need to be passed as separate parameters (not in the filters JSON).
*/
export function extractWorkspaceLinkFilters(params: {
domain?: ParsedFilter;
tagIds?: ParsedFilter;
folderId?: ParsedFilter;
root?: ParsedFilter;
}) {
const extractFilter = (filter?: ParsedFilter) => ({
values: filter?.values,
operator: (filter?.sqlOperator === "NOT IN" ? "NOT IN" : "IN") as
| "IN"
| "NOT IN",
});

const domain = extractFilter(params.domain);
const tagIds = extractFilter(params.tagIds);
const folderId = extractFilter(params.folderId);
const root = extractFilter(params.root);

return {
domain: domain.values,
domainOperator: domain.operator,
tagIds: tagIds.values,
tagIdsOperator: tagIds.operator,
folderId: folderId.values,
folderIdOperator: folderId.operator,
root: root.values,
rootOperator: root.operator,
};
}
Loading