Skip to content

Commit 89b4ab0

Browse files
authored
Merge pull request #2648 from dubinc/filter-events-by-metadata
Filter events & analytics by metadata
2 parents edb8c44 + d6ece15 commit 89b4ab0

File tree

16 files changed

+371
-83
lines changed

16 files changed

+371
-83
lines changed

apps/web/app/(ee)/api/customers/[id]/activity/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export const GET = withWorkspace(async ({ workspace, params }) => {
1717

1818
const events = await getCustomerEvents({
1919
customerId: customer.id,
20-
includeMetadata: true,
2120
});
2221

2322
// get the first partner link that this customer interacted with

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
1010
import { Folder, Link } from "@dub/prisma/client";
1111
import { NextResponse } from "next/server";
1212

13+
// GET /api/events
1314
export const GET = withWorkspace(
1415
async ({ searchParams, workspace, session }) => {
1516
throwIfClicksUsageExceeded(workspace);

apps/web/lib/analytics/get-analytics.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DIMENSIONAL_ANALYTICS_FILTERS,
1313
SINGULAR_ANALYTICS_ENDPOINTS,
1414
} from "./constants";
15+
import { queryParser } from "./query-parser";
1516
import { AnalyticsFilters } from "./types";
1617
import { getStartEndDates } from "./utils/get-start-end-dates";
1718

@@ -32,6 +33,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
3233
timezone = "UTC",
3334
isDeprecatedClicksEndpoint = false,
3435
dataAvailableFrom,
36+
query,
3537
} = params;
3638

3739
const tagIds = combineTagIds(params);
@@ -100,6 +102,8 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
100102
: analyticsResponse[groupBy],
101103
});
102104

105+
const filters = queryParser(query);
106+
103107
const response = await pipe({
104108
...params,
105109
...(UTM_TAGS_PLURAL_LIST.includes(groupBy)
@@ -115,6 +119,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
115119
timezone,
116120
country,
117121
region,
122+
filters: filters ? JSON.stringify(filters) : undefined,
118123
});
119124

120125
if (groupBy === "count") {
@@ -207,13 +212,16 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
207212
},
208213
});
209214

210-
return topPartnersData.map((item) => {
211-
const partner = partners.find((p) => p.id === item.partnerId);
212-
return {
213-
...item,
214-
partner,
215-
};
216-
});
215+
return topPartnersData
216+
.map((item) => {
217+
const partner = partners.find((p) => p.id === item.partnerId);
218+
if (!partner) return null;
219+
return {
220+
...item,
221+
partner,
222+
};
223+
})
224+
.filter((d) => d !== null);
217225
}
218226

219227
// Return array for other endpoints

apps/web/lib/analytics/get-customer-events.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,15 @@ import {
88
clickEventResponseSchema,
99
clickEventSchema,
1010
} from "../zod/schemas/clicks";
11-
import {
12-
leadEventResponseSchema,
13-
leadEventResponseSchemaExtended,
14-
} from "../zod/schemas/leads";
15-
import {
16-
saleEventResponseSchema,
17-
saleEventResponseSchemaExtended,
18-
} from "../zod/schemas/sales";
11+
import { leadEventResponseSchema } from "../zod/schemas/leads";
12+
import { saleEventResponseSchema } from "../zod/schemas/sales";
1913

2014
export const getCustomerEvents = async ({
2115
customerId,
2216
linkIds,
23-
includeMetadata,
2417
}: {
2518
customerId: string;
2619
linkIds?: string[];
27-
includeMetadata?: boolean;
2820
}) => {
2921
const pipe = tb.buildPipe({
3022
pipe: "v2_customer_events",
@@ -68,6 +60,7 @@ export const getCustomerEvents = async ({
6860
? {
6961
eventId: evt.event_id,
7062
eventName: evt.event_name,
63+
metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined,
7164
...(evt.event === "sale"
7265
? {
7366
sale: {
@@ -83,14 +76,8 @@ export const getCustomerEvents = async ({
8376

8477
return {
8578
click: clickEventResponseSchema,
86-
lead: (includeMetadata
87-
? leadEventResponseSchemaExtended
88-
: leadEventResponseSchema
89-
).omit({ customer: true }),
90-
sale: (includeMetadata
91-
? saleEventResponseSchemaExtended
92-
: saleEventResponseSchema
93-
).omit({ customer: true }),
79+
lead: leadEventResponseSchema.omit({ customer: true }),
80+
sale: saleEventResponseSchema.omit({ customer: true }),
9481
}[evt.event].parse(eventData);
9582
})
9683
.filter((d) => d !== null);

apps/web/lib/analytics/get-events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
saleEventResponseSchema,
2222
saleEventSchemaTBEndpoint,
2323
} from "../zod/schemas/sales";
24+
import { queryParser } from "./query-parser";
2425
import { EventsFilters } from "./types";
2526
import { getStartEndDates } from "./utils/get-start-end-dates";
2627

@@ -39,6 +40,7 @@ export const getEvents = async (params: EventsFilters) => {
3940
order,
4041
sortOrder,
4142
dataAvailableFrom,
43+
query,
4244
} = params;
4345

4446
const { startDate, endDate } = getStartEndDates({
@@ -74,6 +76,8 @@ export const getEvents = async (params: EventsFilters) => {
7476
}[eventType] ?? clickEventSchemaTBEndpoint,
7577
});
7678

79+
const filters = queryParser(query);
80+
7781
const response = await pipe({
7882
...params,
7983
eventType,
@@ -85,6 +89,7 @@ export const getEvents = async (params: EventsFilters) => {
8589
offset: (params.page - 1) * params.limit,
8690
start: startDate.toISOString().replace("T", " ").replace("Z", ""),
8791
end: endDate.toISOString().replace("T", " ").replace("Z", ""),
92+
filters: filters ? JSON.stringify(filters) : undefined,
8893
});
8994

9095
const [linksMap, customersMap] = await Promise.all([
@@ -130,6 +135,7 @@ export const getEvents = async (params: EventsFilters) => {
130135
? {
131136
eventId: evt.event_id,
132137
eventName: evt.event_name,
138+
metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined,
133139
customer: customersMap[evt.customer_id] ?? {
134140
id: evt.customer_id,
135141
name: "Deleted Customer",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { EventsFilters } from "./types";
2+
3+
interface InternalFilter {
4+
operand: string;
5+
operator:
6+
| "equals"
7+
| "notEquals"
8+
| "greaterThan"
9+
| "lessThan"
10+
| "greaterThanOrEqual"
11+
| "lessThanOrEqual";
12+
value: string;
13+
}
14+
15+
// Query parser that can parse the query string into a list of filters
16+
export const queryParser = (
17+
query: EventsFilters["query"],
18+
allowedOperands = ["metadata"],
19+
) => {
20+
if (!query) {
21+
return undefined;
22+
}
23+
24+
const filters: InternalFilter[] = [];
25+
26+
// Split the query by logical operators (AND/OR) to handle multiple conditions
27+
// For now, we'll focus on single conditions, but this structure allows for future expansion
28+
const conditions = query.split(/\s+(?:AND|and|OR|or)\s+/);
29+
30+
for (const condition of conditions) {
31+
const trimmedCondition = condition.trim();
32+
33+
if (!trimmedCondition) {
34+
continue;
35+
}
36+
37+
const filter = parseCondition(trimmedCondition);
38+
39+
if (!filter) {
40+
continue;
41+
}
42+
43+
const isAllowed = allowedOperands.some((allowed) => {
44+
if (filter.operand === allowed) {
45+
return true;
46+
}
47+
48+
if (filter.operand.startsWith(`${allowed}.`)) {
49+
return true;
50+
}
51+
52+
return false;
53+
});
54+
55+
if (!isAllowed) {
56+
continue;
57+
}
58+
59+
filters.push(filter);
60+
}
61+
62+
return filters.length > 0 ? filters : undefined;
63+
};
64+
65+
// Parses a single condition in the format: field:value, field>value, or metadata['key']:value
66+
function parseCondition(condition: string): InternalFilter | null {
67+
// This regex captures:
68+
// 1. field - either a regular field name OR metadata with bracket notation (supports both single and double quotes)
69+
// 2. operator - :, >, <, >=, <=, !=
70+
// 3. value - the value after the operator (supports quoted and unquoted values)
71+
const unifiedPattern =
72+
/^([a-zA-Z_][a-zA-Z0-9_]*|metadata\[['"][^'"]*['"]\](?:\[['"][^'"]*['"]\])*)\s*([:><=!]+)\s*(.+)$/;
73+
74+
const match = condition.match(unifiedPattern);
75+
76+
if (!match) {
77+
return null;
78+
}
79+
80+
// Extract the matched groups
81+
const [, fieldOrMetadata, operator, value] = match;
82+
83+
let operand: string;
84+
85+
// Determine the operand based on whether it's metadata or a regular field
86+
if (fieldOrMetadata.startsWith("metadata")) {
87+
const keyPath = fieldOrMetadata.replace(/^metadata/, "");
88+
89+
const extractedKey = keyPath
90+
.replace(/^\[['"]|['"]\]$/g, "") // Remove leading [' or [" and trailing '] or "]
91+
.replace(/\[['"]/g, ".") // Replace [' or [" with .
92+
.replace(/['"]\]/g, ""); // Remove trailing '] or "]
93+
94+
operand = `metadata.${extractedKey}`;
95+
} else {
96+
operand = fieldOrMetadata;
97+
}
98+
99+
return {
100+
operand,
101+
operator: mapOperator(operator),
102+
value: value.trim().replace(/^['"`]|['"`]$/g, ""),
103+
};
104+
}
105+
106+
// Maps operator strings to our internal operator types
107+
function mapOperator(operator: string): InternalFilter["operator"] {
108+
switch (operator) {
109+
case ":":
110+
case "=":
111+
return "equals";
112+
case ">":
113+
return "greaterThan";
114+
case "<":
115+
return "lessThan";
116+
case ">=":
117+
return "greaterThanOrEqual";
118+
case "<=":
119+
return "lessThanOrEqual";
120+
case "!=":
121+
return "notEquals";
122+
default:
123+
// For unsupported operators, default to equals
124+
return "equals";
125+
}
126+
}

apps/web/lib/zod/schemas/analytics.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,16 @@ export const analyticsQuerySchema = z
232232
.describe(
233233
"Filter sales by type: 'new' for first-time purchases, 'recurring' for repeat purchases. If undefined, returns both.",
234234
),
235+
query: z
236+
.string()
237+
.max(10000)
238+
.optional()
239+
.describe(
240+
"Search the events by a custom metadata value. Only available for lead and sale events.",
241+
)
242+
.openapi({
243+
example: "metadata['key']:'value'",
244+
}),
235245
})
236246
.merge(utmTagsSchema);
237247

@@ -261,6 +271,10 @@ export const analyticsFilterTB = z
261271
.optional()
262272
.describe("The folder IDs to retrieve analytics for."),
263273
isMegaFolder: z.boolean().optional(),
274+
filters: z
275+
.string()
276+
.optional()
277+
.describe("The filters to apply to the analytics."),
264278
})
265279
.merge(
266280
analyticsQuerySchema.pick({

apps/web/lib/zod/schemas/leads.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,30 +119,22 @@ export const leadEventSchemaTBEndpoint = z.object({
119119
referer_url_processed: z.string().nullable(),
120120
qr: z.number().nullable(),
121121
ip: z.string().nullable(),
122+
metadata: z.string().nullish(),
122123
});
123124

124125
// response from dub api
125126
export const leadEventResponseSchema = z
126127
.object({
127128
event: z.literal("lead"),
128129
timestamp: z.coerce.string(),
130+
// core event fields
129131
eventId: z.string(),
130132
eventName: z.string(),
133+
metadata: z.any().nullish(),
131134
// nested objects
132135
click: clickEventSchema,
133136
link: linkEventSchema,
134137
customer: CustomerSchema,
135138
})
136139
.merge(commonDeprecatedEventFields)
137140
.openapi({ ref: "LeadEvent", title: "LeadEvent" });
138-
139-
export const leadEventResponseSchemaExtended = leadEventResponseSchema.merge(
140-
z.object({
141-
metadata: z
142-
.string()
143-
.nullish()
144-
.transform((val) => (val === "" ? null : val))
145-
.default(null)
146-
.openapi({ type: "string" }),
147-
}),
148-
);

0 commit comments

Comments
 (0)