diff --git a/apps/web/app/(ee)/api/customers/[id]/activity/route.ts b/apps/web/app/(ee)/api/customers/[id]/activity/route.ts index 576c819be4..3fc3b2c422 100644 --- a/apps/web/app/(ee)/api/customers/[id]/activity/route.ts +++ b/apps/web/app/(ee)/api/customers/[id]/activity/route.ts @@ -17,7 +17,6 @@ export const GET = withWorkspace(async ({ workspace, params }) => { const events = await getCustomerEvents({ customerId: customer.id, - includeMetadata: true, }); // get the first partner link that this customer interacted with diff --git a/apps/web/app/(ee)/api/events/route.ts b/apps/web/app/(ee)/api/events/route.ts index efcb8bfa09..04b0b7487d 100644 --- a/apps/web/app/(ee)/api/events/route.ts +++ b/apps/web/app/(ee)/api/events/route.ts @@ -10,6 +10,7 @@ import { eventsQuerySchema } from "@/lib/zod/schemas/analytics"; import { Folder, Link } from "@dub/prisma/client"; import { NextResponse } from "next/server"; +// GET /api/events export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { throwIfClicksUsageExceeded(workspace); diff --git a/apps/web/lib/analytics/get-analytics.ts b/apps/web/lib/analytics/get-analytics.ts index d7cb198788..15be5ab386 100644 --- a/apps/web/lib/analytics/get-analytics.ts +++ b/apps/web/lib/analytics/get-analytics.ts @@ -12,6 +12,7 @@ import { DIMENSIONAL_ANALYTICS_FILTERS, SINGULAR_ANALYTICS_ENDPOINTS, } from "./constants"; +import { queryParser } from "./query-parser"; import { AnalyticsFilters } from "./types"; import { getStartEndDates } from "./utils/get-start-end-dates"; @@ -32,6 +33,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => { timezone = "UTC", isDeprecatedClicksEndpoint = false, dataAvailableFrom, + query, } = params; const tagIds = combineTagIds(params); @@ -100,6 +102,8 @@ export const getAnalytics = async (params: AnalyticsFilters) => { : analyticsResponse[groupBy], }); + const filters = queryParser(query); + const response = await pipe({ ...params, ...(UTM_TAGS_PLURAL_LIST.includes(groupBy) @@ -115,6 +119,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => { timezone, country, region, + filters: filters ? JSON.stringify(filters) : undefined, }); if (groupBy === "count") { @@ -207,13 +212,16 @@ export const getAnalytics = async (params: AnalyticsFilters) => { }, }); - return topPartnersData.map((item) => { - const partner = partners.find((p) => p.id === item.partnerId); - return { - ...item, - partner, - }; - }); + return topPartnersData + .map((item) => { + const partner = partners.find((p) => p.id === item.partnerId); + if (!partner) return null; + return { + ...item, + partner, + }; + }) + .filter((d) => d !== null); } // Return array for other endpoints diff --git a/apps/web/lib/analytics/get-customer-events.ts b/apps/web/lib/analytics/get-customer-events.ts index 80b942a261..8f1ad591a0 100644 --- a/apps/web/lib/analytics/get-customer-events.ts +++ b/apps/web/lib/analytics/get-customer-events.ts @@ -8,23 +8,15 @@ import { clickEventResponseSchema, clickEventSchema, } from "../zod/schemas/clicks"; -import { - leadEventResponseSchema, - leadEventResponseSchemaExtended, -} from "../zod/schemas/leads"; -import { - saleEventResponseSchema, - saleEventResponseSchemaExtended, -} from "../zod/schemas/sales"; +import { leadEventResponseSchema } from "../zod/schemas/leads"; +import { saleEventResponseSchema } from "../zod/schemas/sales"; export const getCustomerEvents = async ({ customerId, linkIds, - includeMetadata, }: { customerId: string; linkIds?: string[]; - includeMetadata?: boolean; }) => { const pipe = tb.buildPipe({ pipe: "v2_customer_events", @@ -68,6 +60,7 @@ export const getCustomerEvents = async ({ ? { eventId: evt.event_id, eventName: evt.event_name, + metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined, ...(evt.event === "sale" ? { sale: { @@ -83,14 +76,8 @@ export const getCustomerEvents = async ({ return { click: clickEventResponseSchema, - lead: (includeMetadata - ? leadEventResponseSchemaExtended - : leadEventResponseSchema - ).omit({ customer: true }), - sale: (includeMetadata - ? saleEventResponseSchemaExtended - : saleEventResponseSchema - ).omit({ customer: true }), + lead: leadEventResponseSchema.omit({ customer: true }), + sale: saleEventResponseSchema.omit({ customer: true }), }[evt.event].parse(eventData); }) .filter((d) => d !== null); diff --git a/apps/web/lib/analytics/get-events.ts b/apps/web/lib/analytics/get-events.ts index 231f65a4a4..bf8c75773f 100644 --- a/apps/web/lib/analytics/get-events.ts +++ b/apps/web/lib/analytics/get-events.ts @@ -21,6 +21,7 @@ import { saleEventResponseSchema, saleEventSchemaTBEndpoint, } from "../zod/schemas/sales"; +import { queryParser } from "./query-parser"; import { EventsFilters } from "./types"; import { getStartEndDates } from "./utils/get-start-end-dates"; @@ -39,6 +40,7 @@ export const getEvents = async (params: EventsFilters) => { order, sortOrder, dataAvailableFrom, + query, } = params; const { startDate, endDate } = getStartEndDates({ @@ -74,6 +76,8 @@ export const getEvents = async (params: EventsFilters) => { }[eventType] ?? clickEventSchemaTBEndpoint, }); + const filters = queryParser(query); + const response = await pipe({ ...params, eventType, @@ -85,6 +89,7 @@ export const getEvents = async (params: EventsFilters) => { offset: (params.page - 1) * params.limit, start: startDate.toISOString().replace("T", " ").replace("Z", ""), end: endDate.toISOString().replace("T", " ").replace("Z", ""), + filters: filters ? JSON.stringify(filters) : undefined, }); const [linksMap, customersMap] = await Promise.all([ @@ -130,6 +135,7 @@ export const getEvents = async (params: EventsFilters) => { ? { eventId: evt.event_id, eventName: evt.event_name, + metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined, customer: customersMap[evt.customer_id] ?? { id: evt.customer_id, name: "Deleted Customer", diff --git a/apps/web/lib/analytics/query-parser.ts b/apps/web/lib/analytics/query-parser.ts new file mode 100644 index 0000000000..8518732a31 --- /dev/null +++ b/apps/web/lib/analytics/query-parser.ts @@ -0,0 +1,126 @@ +import { EventsFilters } from "./types"; + +interface InternalFilter { + operand: string; + operator: + | "equals" + | "notEquals" + | "greaterThan" + | "lessThan" + | "greaterThanOrEqual" + | "lessThanOrEqual"; + value: string; +} + +// Query parser that can parse the query string into a list of filters +export const queryParser = ( + query: EventsFilters["query"], + allowedOperands = ["metadata"], +) => { + if (!query) { + return undefined; + } + + const filters: InternalFilter[] = []; + + // Split the query by logical operators (AND/OR) to handle multiple conditions + // For now, we'll focus on single conditions, but this structure allows for future expansion + const conditions = query.split(/\s+(?:AND|and|OR|or)\s+/); + + for (const condition of conditions) { + const trimmedCondition = condition.trim(); + + if (!trimmedCondition) { + continue; + } + + const filter = parseCondition(trimmedCondition); + + if (!filter) { + continue; + } + + const isAllowed = allowedOperands.some((allowed) => { + if (filter.operand === allowed) { + return true; + } + + if (filter.operand.startsWith(`${allowed}.`)) { + return true; + } + + return false; + }); + + if (!isAllowed) { + continue; + } + + filters.push(filter); + } + + return filters.length > 0 ? filters : undefined; +}; + +// Parses a single condition in the format: field:value, field>value, or metadata['key']:value +function parseCondition(condition: string): InternalFilter | null { + // This regex captures: + // 1. field - either a regular field name OR metadata with bracket notation (supports both single and double quotes) + // 2. operator - :, >, <, >=, <=, != + // 3. value - the value after the operator (supports quoted and unquoted values) + const unifiedPattern = + /^([a-zA-Z_][a-zA-Z0-9_]*|metadata\[['"][^'"]*['"]\](?:\[['"][^'"]*['"]\])*)\s*([:><=!]+)\s*(.+)$/; + + const match = condition.match(unifiedPattern); + + if (!match) { + return null; + } + + // Extract the matched groups + const [, fieldOrMetadata, operator, value] = match; + + let operand: string; + + // Determine the operand based on whether it's metadata or a regular field + if (fieldOrMetadata.startsWith("metadata")) { + const keyPath = fieldOrMetadata.replace(/^metadata/, ""); + + const extractedKey = keyPath + .replace(/^\[['"]|['"]\]$/g, "") // Remove leading [' or [" and trailing '] or "] + .replace(/\[['"]/g, ".") // Replace [' or [" with . + .replace(/['"]\]/g, ""); // Remove trailing '] or "] + + operand = `metadata.${extractedKey}`; + } else { + operand = fieldOrMetadata; + } + + return { + operand, + operator: mapOperator(operator), + value: value.trim().replace(/^['"`]|['"`]$/g, ""), + }; +} + +// Maps operator strings to our internal operator types +function mapOperator(operator: string): InternalFilter["operator"] { + switch (operator) { + case ":": + case "=": + return "equals"; + case ">": + return "greaterThan"; + case "<": + return "lessThan"; + case ">=": + return "greaterThanOrEqual"; + case "<=": + return "lessThanOrEqual"; + case "!=": + return "notEquals"; + default: + // For unsupported operators, default to equals + return "equals"; + } +} diff --git a/apps/web/lib/zod/schemas/analytics.ts b/apps/web/lib/zod/schemas/analytics.ts index c928349c04..1c9e2dcabb 100644 --- a/apps/web/lib/zod/schemas/analytics.ts +++ b/apps/web/lib/zod/schemas/analytics.ts @@ -232,6 +232,16 @@ export const analyticsQuerySchema = z .describe( "Filter sales by type: 'new' for first-time purchases, 'recurring' for repeat purchases. If undefined, returns both.", ), + query: z + .string() + .max(10000) + .optional() + .describe( + "Search the events by a custom metadata value. Only available for lead and sale events.", + ) + .openapi({ + example: "metadata['key']:'value'", + }), }) .merge(utmTagsSchema); @@ -261,6 +271,10 @@ export const analyticsFilterTB = z .optional() .describe("The folder IDs to retrieve analytics for."), isMegaFolder: z.boolean().optional(), + filters: z + .string() + .optional() + .describe("The filters to apply to the analytics."), }) .merge( analyticsQuerySchema.pick({ diff --git a/apps/web/lib/zod/schemas/leads.ts b/apps/web/lib/zod/schemas/leads.ts index b206d13b09..ee89bc0e55 100644 --- a/apps/web/lib/zod/schemas/leads.ts +++ b/apps/web/lib/zod/schemas/leads.ts @@ -119,6 +119,7 @@ export const leadEventSchemaTBEndpoint = z.object({ referer_url_processed: z.string().nullable(), qr: z.number().nullable(), ip: z.string().nullable(), + metadata: z.string().nullish(), }); // response from dub api @@ -126,8 +127,10 @@ export const leadEventResponseSchema = z .object({ event: z.literal("lead"), timestamp: z.coerce.string(), + // core event fields eventId: z.string(), eventName: z.string(), + metadata: z.any().nullish(), // nested objects click: clickEventSchema, link: linkEventSchema, @@ -135,14 +138,3 @@ export const leadEventResponseSchema = z }) .merge(commonDeprecatedEventFields) .openapi({ ref: "LeadEvent", title: "LeadEvent" }); - -export const leadEventResponseSchemaExtended = leadEventResponseSchema.merge( - z.object({ - metadata: z - .string() - .nullish() - .transform((val) => (val === "" ? null : val)) - .default(null) - .openapi({ type: "string" }), - }), -); diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index 6c3eb58e1a..54b9c4a44e 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -133,6 +133,7 @@ export const saleEventSchemaTBEndpoint = z.object({ referer_url_processed: z.string().nullable(), qr: z.number().nullable(), ip: z.string().nullable(), + metadata: z.string().nullish(), }); // response from dub api @@ -140,17 +141,20 @@ export const saleEventResponseSchema = z .object({ event: z.literal("sale"), timestamp: z.coerce.string(), + // core event fields eventId: z.string(), eventName: z.string(), - // nested objects - link: linkEventSchema, - click: clickEventSchema, - customer: CustomerSchema, sale: trackSaleRequestSchema.pick({ amount: true, invoiceId: true, paymentProcessor: true, }), + metadata: z.any().nullish(), + // nested objects + link: linkEventSchema, + click: clickEventSchema, + customer: CustomerSchema, + // deprecated fields saleAmount: z .number() .describe("Deprecated. Use `sale.amount` instead.") @@ -165,14 +169,3 @@ export const saleEventResponseSchema = z }) .merge(commonDeprecatedEventFields) .openapi({ ref: "SaleEvent", title: "SaleEvent" }); - -export const saleEventResponseSchemaExtended = saleEventResponseSchema.merge( - z.object({ - metadata: z - .string() - .nullish() - .transform((val) => (val === "" ? null : val)) - .default(null) - .openapi({ type: "string" }), - }), -); diff --git a/apps/web/tests/analytics/index.test.ts b/apps/web/tests/analytics/index.test.ts index f32f0ca79d..4a2e3f01ae 100644 --- a/apps/web/tests/analytics/index.test.ts +++ b/apps/web/tests/analytics/index.test.ts @@ -39,4 +39,23 @@ describe.runIf(env.CI).sequential("GET /analytics", async () => { expect(parsed.success).toBeTruthy(); }); }); + + test("filter events by metadata.productId", async () => { + const { status, data } = await http.get({ + path: `/events`, + query: { + event: "sales", + workspaceId, + interval: "30d", + query: "metadata['productId']:premiumProductId", + }, + }); + + expect(status).toEqual(200); + + // check to make sure all events have metadata.productId equal to premiumProductId + expect( + data.every((event) => event.metadata?.productId === "premiumProductId"), + ).toBe(true); + }); }); diff --git a/apps/web/tests/analytics/query-parser.test.ts b/apps/web/tests/analytics/query-parser.test.ts new file mode 100644 index 0000000000..156af8a8e2 --- /dev/null +++ b/apps/web/tests/analytics/query-parser.test.ts @@ -0,0 +1,74 @@ +import { queryParser } from "@/lib/analytics/query-parser"; +import { describe, expect, it } from "vitest"; + +describe("Analytics Query Parser", () => { + it("should parse simple nested property", () => { + const result = queryParser("metadata['key']:value"); + expect(result).toEqual([ + { operand: "metadata.key", operator: "equals", value: "value" }, + ]); + }); + + it("should parse nested property with double quotes", () => { + const result = queryParser('metadata["key"]:"quoted value"'); + expect(result).toEqual([ + { operand: "metadata.key", operator: "equals", value: "quoted value" }, + ]); + }); + + it("should parse deeply nested property", () => { + const result = queryParser("metadata['level1']['level2']['level3']:value"); + expect(result).toEqual([ + { + operand: "metadata.level1.level2.level3", + operator: "equals", + value: "value", + }, + ]); + }); + + it("should parse nested property with complex path", () => { + const result = queryParser("metadata['user']['preferences']['theme']:dark"); + expect(result).toEqual([ + { + operand: "metadata.user.preferences.theme", + operator: "equals", + value: "dark", + }, + ]); + }); + + it("should parse equals operator (:) for nested property", () => { + const result = queryParser("metadata['key']:value"); + expect(result).toEqual([ + { operand: "metadata.key", operator: "equals", value: "value" }, + ]); + }); + + it("should parse not equals operator for nested property", () => { + const result = queryParser("metadata['status']!=completed"); + expect(result).toEqual([ + { operand: "metadata.status", operator: "notEquals", value: "completed" }, + ]); + }); + + it("should handle empty query", () => { + const result = queryParser(""); + expect(result).toBeUndefined(); + }); + + it("should handle null query", () => { + const result = queryParser(null as any); + expect(result).toBeUndefined(); + }); + + it("should handle undefined query", () => { + const result = queryParser(undefined as any); + expect(result).toBeUndefined(); + }); + + it("should handle whitespace-only query", () => { + const result = queryParser(" "); + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/web/ui/analytics/events/events-table.tsx b/apps/web/ui/analytics/events/events-table.tsx index 10f326928e..b109440566 100644 --- a/apps/web/ui/analytics/events/events-table.tsx +++ b/apps/web/ui/analytics/events/events-table.tsx @@ -42,6 +42,7 @@ import DeviceIcon from "../device-icon"; import { TRIGGER_DISPLAY } from "../trigger-display"; import { EventsContext } from "./events-provider"; import { EXAMPLE_EVENTS_DATA } from "./example-data"; +import { MetadataViewer } from "./metadata-viewer"; import { RowMenuButton } from "./row-menu-button"; import { eventColumns, useColumnVisibility } from "./use-column-visibility"; @@ -473,6 +474,22 @@ export default function EventsTable({ ), }, ]), + // Metadata + { + id: "metadata", + header: "Metadata", + accessorKey: "metadata", + minSize: 120, + size: 120, + maxSize: 120, + cell: ({ getValue }) => { + const metadata = getValue(); + if (!metadata || Object.keys(metadata).length === 0) { + return -; + } + return ; + }, + }, // Menu { id: "menu", diff --git a/apps/web/ui/analytics/events/metadata-viewer.tsx b/apps/web/ui/analytics/events/metadata-viewer.tsx index 8474291379..9077061745 100644 --- a/apps/web/ui/analytics/events/metadata-viewer.tsx +++ b/apps/web/ui/analytics/events/metadata-viewer.tsx @@ -1,13 +1,15 @@ import { Button, Tooltip, useCopyToClipboard } from "@dub/ui"; -import { cn, truncate } from "@dub/utils"; +import { cn, pluralize, truncate } from "@dub/utils"; import { Check, Copy } from "lucide-react"; import { Fragment } from "react"; // Display the event metadata export function MetadataViewer({ metadata, + previewItems = 3, }: { metadata: Record; + previewItems?: number; }) { const [copied, copyToClipboard] = useCopyToClipboard(); @@ -34,9 +36,9 @@ export function MetadataViewer({ }) .flat(); - const hasMoreItems = displayEntries.length > 3; + const hasMoreItems = displayEntries.length > previewItems; const visibleEntries = hasMoreItems - ? displayEntries.slice(0, 3) + ? displayEntries.slice(0, previewItems) : displayEntries; return ( @@ -93,7 +95,10 @@ export function MetadataViewer({ className="rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 hover:bg-neutral-50" > {hasMoreItems - ? `+${displayEntries.length - 3} more` + ? `+${displayEntries.length - previewItems} ${pluralize( + "item", + displayEntries.length - previewItems, + )}` : "View metadata"} diff --git a/apps/web/ui/analytics/events/use-column-visibility.ts b/apps/web/ui/analytics/events/use-column-visibility.ts index 79b8f86c1f..e5d7b82e49 100644 --- a/apps/web/ui/analytics/events/use-column-visibility.ts +++ b/apps/web/ui/analytics/events/use-column-visibility.ts @@ -5,6 +5,7 @@ import { VisibilityState } from "@tanstack/react-table"; export const eventColumns = { clicks: { all: [ + "timestamp", "trigger", "link", "country", @@ -17,12 +18,12 @@ export const eventColumns = { "referer", "refererUrl", "ip", - "timestamp", ], defaultVisible: ["timestamp", "link", "referer", "country", "device"], }, leads: { all: [ + "timestamp", "event", "link", "customer", @@ -36,12 +37,14 @@ export const eventColumns = { "referer", "refererUrl", "ip", - "timestamp", + "metadata", ], defaultVisible: ["timestamp", "event", "link", "customer", "referer"], }, sales: { all: [ + "timestamp", + "saleAmount", "event", "customer", "link", @@ -56,8 +59,7 @@ export const eventColumns = { "referer", "refererUrl", "ip", - "timestamp", - "saleAmount", + "metadata", ], defaultVisible: [ "timestamp", diff --git a/apps/web/ui/customers/customer-activity-list.tsx b/apps/web/ui/customers/customer-activity-list.tsx index a6037f6dcd..de7d52aecc 100644 --- a/apps/web/ui/customers/customer-activity-list.tsx +++ b/apps/web/ui/customers/customer-activity-list.tsx @@ -85,18 +85,10 @@ const activityData = { lead: { icon: UserCheck, content: (event) => { - let metadata = null; - - try { - metadata = event.metadata ? JSON.parse(event.metadata) : null; - } catch (e) { - // - } - return (
{event.eventName || "New lead"} - {metadata && } + {event.metadata && }
); }, @@ -105,18 +97,10 @@ const activityData = { sale: { icon: MoneyBill2, content: (event) => { - let metadata = null; - - try { - metadata = event.metadata ? JSON.parse(event.metadata) : null; - } catch (e) { - // - } - return (
{event.eventName || "New sale"} - {metadata && } + {event.metadata && }
); }, diff --git a/packages/tinybird/pipes/v2_events.pipe b/packages/tinybird/pipes/v2_events.pipe index 9903f2010e..0ff64bcb57 100644 --- a/packages/tinybird/pipes/v2_events.pipe +++ b/packages/tinybird/pipes/v2_events.pipe @@ -134,6 +134,31 @@ SQL > AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') {% end %} {% if defined(url) %} AND url = {{ url }} {% end %} + + {% if defined(filters) %} + {% for item in JSON(filters, '[]') %} + {% if item.get('operand', '').startswith('metadata.') %} + {% set metadataKey = item.get('operand', '').split('.')[1] %} + {% set operator = item.get('operator', 'equals') %} + {% set value = item.get('value', '') %} + + {% if operator == 'equals' %} + AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }} + {% elif operator == 'notEquals' %} + AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }} + {% elif operator == 'greaterThan' %} + AND JSONExtractString(metadata, {{ metadataKey }}) > {{ value }} + {% elif operator == 'lessThan' %} + AND JSONExtractString(metadata, {{ metadataKey }}) < {{ value }} + {% elif operator == 'greaterThanOrEqual' %} + AND JSONExtractString(metadata, {{ metadataKey }}) >= {{ value }} + {% elif operator == 'lessThanOrEqual' %} + AND JSONExtractString(metadata, {{ metadataKey }}) <= {{ value }} + {% end %} + {% end %} + {% end %} + {% end %} + ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %} LIMIT {{ Int32(limit, 100) }} {% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %} @@ -169,12 +194,48 @@ SQL > {% if defined(os) %} AND os = {{ os }} {% end %} {% if defined(referer) %} AND referer = {{ referer }} {% end %} {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %} - {% if defined(utm_source) %} AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') {% end %} - {% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') {% end %} - {% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') {% end %} - {% if defined(utm_term) %} AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') {% end %} - {% if defined(utm_content) %} AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') {% end %} + {% if defined(utm_source) %} + AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') + {% end %} + {% if defined(utm_medium) %} + AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') + {% end %} + {% if defined(utm_campaign) %} + AND url + LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') + {% end %} + {% if defined(utm_term) %} + AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') + {% end %} + {% if defined(utm_content) %} + AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') + {% end %} {% if defined(url) %} AND url = {{ url }} {% end %} + + {% if defined(filters) %} + {% for item in JSON(filters, '[]') %} + {% if item.get('operand', '').startswith('metadata.') %} + {% set metadataKey = item.get('operand', '').split('.')[1] %} + {% set operator = item.get('operator', 'equals') %} + {% set value = item.get('value', '') %} + + {% if operator == 'equals' %} + AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }} + {% elif operator == 'notEquals' %} + AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }} + {% elif operator == 'greaterThan' %} + AND JSONExtractString(metadata, {{ metadataKey }}) > {{ value }} + {% elif operator == 'lessThan' %} + AND JSONExtractString(metadata, {{ metadataKey }}) < {{ value }} + {% elif operator == 'greaterThanOrEqual' %} + AND JSONExtractString(metadata, {{ metadataKey }}) >= {{ value }} + {% elif operator == 'lessThanOrEqual' %} + AND JSONExtractString(metadata, {{ metadataKey }}) <= {{ value }} + {% end %} + {% end %} + {% end %} + {% end %} + ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %} LIMIT {{ Int32(limit, 100) }} {% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}