Skip to content

Commit c6ae269

Browse files
authored
Merge pull request #3436 from pepeladeira/main
Advanced Analytics Filters
2 parents faf4384 + 7d45f87 commit c6ae269

38 files changed

+4222
-717
lines changed

apps/web/app/(ee)/api/admin/analytics/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { getAnalytics } from "@/lib/analytics/get-analytics";
22
import { withAdmin } from "@/lib/auth";
3-
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
3+
import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics";
44
import { NextResponse } from "next/server";
55

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

1010
const response = await getAnalytics(parsedParams);
1111

apps/web/app/(ee)/api/admin/events/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { getEvents } from "@/lib/analytics/get-events";
22
import { withAdmin } from "@/lib/auth";
3-
import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
3+
import { parseEventsQuery } from "@/lib/zod/schemas/analytics";
44
import { NextResponse } from "next/server";
55

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

1010
const response = await getEvents(parsedParams);
1111

apps/web/app/(ee)/api/cron/usage/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export const updateUsage = async () => {
122122
event: "clicks",
123123
groupBy: "top_links",
124124
interval: "30d",
125-
root: false,
125+
root: { values: ["false"], operator: "IS", sqlOperator: "IN" },
126126
});
127127

128128
const topFive = topLinks.slice(0, 5);

apps/web/app/(ee)/api/events/route.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getFirstFilterValue } from "@/lib/analytics/filter-helpers";
12
import { getEvents } from "@/lib/analytics/get-events";
23
import { getFolderIdsToFilter } from "@/lib/analytics/get-folder-ids-to-filter";
34
import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw";
@@ -6,7 +7,7 @@ import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks";
67
import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan";
78
import { withWorkspace } from "@/lib/auth";
89
import { verifyFolderAccess } from "@/lib/folder/permissions";
9-
import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
10+
import { parseEventsQuery } from "@/lib/zod/schemas/analytics";
1011
import { Link } from "@dub/prisma/client";
1112
import { NextResponse } from "next/server";
1213

@@ -15,7 +16,7 @@ export const GET = withWorkspace(
1516
async ({ searchParams, workspace, session }) => {
1617
throwIfClicksUsageExceeded(workspace);
1718

18-
const parsedParams = eventsQuerySchema.parse(searchParams);
19+
const parsedParams = parseEventsQuery(searchParams);
1920

2021
let {
2122
event,
@@ -24,11 +25,15 @@ export const GET = withWorkspace(
2425
end,
2526
linkId,
2627
externalId,
27-
domain,
28+
domain: domainFilter,
2829
key,
29-
folderId,
30+
folderId: folderIdFilter,
3031
} = parsedParams;
3132

33+
// Extract string values for specific link/folder lookup
34+
const domain = getFirstFilterValue(domainFilter);
35+
const folderId = getFirstFilterValue(folderIdFilter);
36+
3237
let link: Link | null = null;
3338

3439
if (domain) {
@@ -78,7 +83,7 @@ export const GET = withWorkspace(
7883
...(link && { linkId: link.id }),
7984
workspaceId: workspace.id,
8085
folderIds,
81-
folderId: folderId || "",
86+
folderId: folderIdFilter || undefined,
8287
});
8388
console.timeEnd("getEvents");
8489

apps/web/app/api/analytics/dashboard/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { getFirstFilterValue } from "@/lib/analytics/filter-helpers";
12
import { getAnalytics } from "@/lib/analytics/get-analytics";
23
import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
34
import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan";
45
import { exceededLimitError } from "@/lib/exceeded-limit-error";
56
import { PlanProps } from "@/lib/types";
67
import { redis } from "@/lib/upstash";
7-
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
8+
import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics";
89
import { prisma } from "@dub/prisma";
910
import { DUB_DEMO_LINKS, DUB_WORKSPACE_ID, getSearchParams } from "@dub/utils";
1011
import { waitUntil } from "@vercel/functions";
@@ -16,9 +17,13 @@ export const dynamic = "force-dynamic";
1617
export const GET = async (req: Request) => {
1718
try {
1819
const searchParams = getSearchParams(req.url);
19-
const parsedParams = analyticsQuerySchema.parse(searchParams);
20+
const parsedParams = parseAnalyticsQuery(searchParams);
2021

21-
const { domain, key, folderId, interval, start, end } = parsedParams;
22+
const { domain: domainFilter, key, folderId: folderIdFilter, interval, start, end } = parsedParams;
23+
24+
// Extract string values for specific link/folder lookup
25+
const domain = getFirstFilterValue(domainFilter);
26+
const folderId = getFirstFilterValue(folderIdFilter);
2227

2328
if ((!domain || !key) && !folderId) {
2429
throw new DubApiError({

apps/web/app/api/analytics/export/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { VALID_ANALYTICS_ENDPOINTS } from "@/lib/analytics/constants";
2+
import { getFirstFilterValue } from "@/lib/analytics/filter-helpers";
23
import { getAnalytics } from "@/lib/analytics/get-analytics";
34
import { getFolderIdsToFilter } from "@/lib/analytics/get-folder-ids-to-filter";
45
import { convertToCSV } from "@/lib/analytics/utils";
@@ -8,7 +9,7 @@ import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks";
89
import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan";
910
import { withWorkspace } from "@/lib/auth";
1011
import { verifyFolderAccess } from "@/lib/folder/permissions";
11-
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
12+
import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics";
1213
import { Link } from "@dub/prisma/client";
1314
import JSZip from "jszip";
1415

@@ -17,11 +18,15 @@ export const GET = withWorkspace(
1718
async ({ searchParams, workspace, session }) => {
1819
throwIfClicksUsageExceeded(workspace);
1920

20-
const parsedParams = analyticsQuerySchema.parse(searchParams);
21+
const parsedParams = parseAnalyticsQuery(searchParams);
2122

22-
const { interval, start, end, linkId, externalId, domain, key, folderId } =
23+
const { interval, start, end, linkId, externalId, domain: domainFilter, key, folderId: folderIdFilter } =
2324
parsedParams;
2425

26+
// Extract string values for specific link/folder lookup
27+
const domain = getFirstFilterValue(domainFilter);
28+
const folderId = getFirstFilterValue(folderIdFilter);
29+
2530
let link: Link | null = null;
2631

2732
if (domain) {
@@ -80,7 +85,7 @@ export const GET = withWorkspace(
8085
...(link && { linkId: link.id }),
8186
groupBy: endpoint,
8287
folderIds,
83-
folderId: folderId || "",
88+
folderId: folderIdFilter || undefined,
8489
});
8590

8691
if (!response || response.length === 0) return;

apps/web/app/api/analytics/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { VALID_ANALYTICS_ENDPOINTS } from "@/lib/analytics/constants";
2+
import { getFirstFilterValue } from "@/lib/analytics/filter-helpers";
23
import { getAnalytics } from "@/lib/analytics/get-analytics";
34
import { getFolderIdsToFilter } from "@/lib/analytics/get-folder-ids-to-filter";
45
import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw";
@@ -13,7 +14,7 @@ import { withWorkspace } from "@/lib/auth";
1314
import { verifyFolderAccess } from "@/lib/folder/permissions";
1415
import {
1516
analyticsPathParamsSchema,
16-
analyticsQuerySchema,
17+
parseAnalyticsQuery,
1718
} from "@/lib/zod/schemas/analytics";
1819
import { Link } from "@dub/prisma/client";
1920
import { NextResponse } from "next/server";
@@ -32,7 +33,7 @@ export const GET = withWorkspace(
3233
oldEvent = undefined;
3334
}
3435

35-
const parsedParams = analyticsQuerySchema.parse(searchParams);
36+
const parsedParams = parseAnalyticsQuery(searchParams);
3637

3738
let {
3839
event,
@@ -42,12 +43,17 @@ export const GET = withWorkspace(
4243
end,
4344
linkId,
4445
externalId,
45-
domain,
46+
domain: domainFilter,
4647
key,
47-
folderId,
48+
folderId: folderIdFilter,
4849
programId,
4950
} = parsedParams;
5051

52+
// Extract string values for specific link/folder lookup
53+
// When domain+key is provided, it's for getting a specific link (not filtering)
54+
const domain = getFirstFilterValue(domainFilter);
55+
const folderId = getFirstFilterValue(folderIdFilter);
56+
5157
let link: Link | null = null;
5258

5359
event = oldEvent || event;

apps/web/app/app.dub.co/(dashboard)/[slug]/links/page-client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function WorkspaceLinks() {
9393
activeFilters,
9494
onSelect,
9595
onRemove,
96+
onRemoveFilter,
9697
onRemoveAll,
9798
setSearch,
9899
setSelectedFilter,
@@ -250,6 +251,7 @@ function WorkspaceLinks() {
250251
activeFilters={activeFilters}
251252
onSelect={onSelect}
252253
onRemove={onRemove}
254+
onRemoveFilter={onRemoveFilter}
253255
onRemoveAll={onRemoveAll}
254256
/>
255257
</PageWidthWrapper>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { ParsedFilter, type SQLOperator } from "@dub/utils";
2+
3+
/**
4+
* Advanced filter structure for Tinybird's filters JSON parameter.
5+
* Used for event-level dimensional filters.
6+
*/
7+
export interface AdvancedFilter {
8+
field: string;
9+
operator: SQLOperator;
10+
values: string[];
11+
}
12+
13+
/**
14+
* Extract the first string value from a ParsedFilter.
15+
* Useful for API routes that need a single value (e.g., domain, folderId)
16+
* for lookups, even when the filter supports multiple values.
17+
*/
18+
export function getFirstFilterValue(
19+
filter: ParsedFilter | string | undefined,
20+
): string | undefined {
21+
if (!filter) return undefined;
22+
if (typeof filter === "string") return filter;
23+
return filter.values?.[0];
24+
}
25+
26+
/**
27+
* Prepare trigger and region filters for Tinybird pipes.
28+
* Handles backward compatibility for qr parameter and region splitting.
29+
*/
30+
export function prepareFiltersForPipe(params: {
31+
qr?: boolean;
32+
trigger?: ParsedFilter;
33+
region?: string | ParsedFilter;
34+
country?: ParsedFilter;
35+
}) {
36+
// Handle qr backward compatibility
37+
let triggerForPipe = params.trigger;
38+
if (params.qr && !params.trigger) {
39+
triggerForPipe = {
40+
operator: "IS" as const,
41+
sqlOperator: "IN" as const,
42+
values: ["qr"],
43+
};
44+
}
45+
46+
// Handle region split (format: "US-CA")
47+
let countryForPipe = params.country;
48+
let regionForPipe = params.region;
49+
if (params.region && typeof params.region === "string") {
50+
const split = params.region.split("-");
51+
countryForPipe = {
52+
operator: "IS" as const,
53+
sqlOperator: "IN" as const,
54+
values: [split[0]],
55+
};
56+
regionForPipe = split[1];
57+
}
58+
59+
return { triggerForPipe, countryForPipe, regionForPipe };
60+
}
61+
62+
/**
63+
* Extract workspace link filters (domain, tagIds, folderId, root) into
64+
* separate values and operators for Tinybird.
65+
*
66+
* These filters are applied on the workspace_links node in Tinybird,
67+
* so they need to be passed as separate parameters (not in the filters JSON).
68+
*/
69+
export function extractWorkspaceLinkFilters(params: {
70+
domain?: ParsedFilter;
71+
tagIds?: ParsedFilter;
72+
folderId?: ParsedFilter;
73+
root?: ParsedFilter;
74+
}) {
75+
const extractFilter = (filter?: ParsedFilter) => ({
76+
values: filter?.values,
77+
operator: (filter?.sqlOperator === "NOT IN" ? "NOT IN" : "IN") as
78+
| "IN"
79+
| "NOT IN",
80+
});
81+
82+
const domain = extractFilter(params.domain);
83+
const tagIds = extractFilter(params.tagIds);
84+
const folderId = extractFilter(params.folderId);
85+
const root = extractFilter(params.root);
86+
87+
return {
88+
domain: domain.values,
89+
domainOperator: domain.operator,
90+
tagIds: tagIds.values,
91+
tagIdsOperator: tagIds.operator,
92+
folderId: folderId.values,
93+
folderIdOperator: folderId.operator,
94+
root: root.values,
95+
rootOperator: root.operator,
96+
};
97+
}
98+
99+
/**
100+
* Build advanced filters array for Tinybird's filters JSON parameter.
101+
* Extracts event-level dimensional filters from params and formats them
102+
* for the filters JSON that gets passed to Tinybird pipes.
103+
*/
104+
const SUPPORTED_FIELDS = [
105+
"country",
106+
"city",
107+
"continent",
108+
"device",
109+
"browser",
110+
"os",
111+
"referer",
112+
"refererUrl",
113+
"url",
114+
"trigger",
115+
"utm_source",
116+
"utm_medium",
117+
"utm_campaign",
118+
"utm_term",
119+
"utm_content",
120+
"saleType",
121+
] as const;
122+
123+
type SupportedField = (typeof SUPPORTED_FIELDS)[number];
124+
125+
export function buildAdvancedFilters(
126+
params: Partial<Record<SupportedField, ParsedFilter | undefined>>,
127+
): AdvancedFilter[] {
128+
const filters: AdvancedFilter[] = [];
129+
130+
for (const field of SUPPORTED_FIELDS) {
131+
const parsed = params[field];
132+
if (!parsed) continue;
133+
134+
filters.push({
135+
field,
136+
operator: parsed.sqlOperator,
137+
values: parsed.values,
138+
});
139+
}
140+
141+
return filters;
142+
}

0 commit comments

Comments
 (0)