From 77dda7268ee3574e42df138e913ef52557f8f92e Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 24 Apr 2026 11:39:40 -0700 Subject: [PATCH] add event-data property filtering --- public/intl/messages/en-US.json | 1 + .../event-data/EventDataFilterBar.tsx | 96 ++++++++++++++ .../event-data/EventDataFilterButton.tsx | 34 +++++ .../event-data/EventDataFilterEditForm.tsx | 108 ++++++++++++++++ .../event-data/EventDataFilterRecord.tsx | 121 ++++++++++++++++++ .../event-data/EventDataNumericChart.tsx | 5 + .../event-data/EventDataPivotTable.tsx | 5 +- .../event-data/EventDataPropertyChart.tsx | 103 ++++++++------- .../[websiteId]/events/EventProperties.tsx | 96 +++++++++----- .../event-data-pivot/numeric-series/route.ts | 4 +- .../event-data-pivot/property-series/route.ts | 4 +- .../[websiteId]/event-data-pivot/route.ts | 4 +- .../queries/useEventDataNumericSeriesQuery.ts | 7 +- .../hooks/queries/useEventDataPivotQuery.ts | 7 +- .../useEventDataPropertySeriesQuery.ts | 7 +- src/components/input/FilterBar.tsx | 5 +- src/components/messages.ts | 1 + src/lib/clickhouse.ts | 78 ++++++++++- src/lib/params.ts | 30 ++++- src/lib/prisma.ts | 67 +++++++++- src/lib/request.ts | 8 +- src/lib/types.ts | 7 + .../sql/events/getEventDataNumericSeries.ts | 17 ++- src/queries/sql/events/getEventDataPivot.ts | 31 +++-- .../sql/events/getEventDataPropertySeries.ts | 24 +++- 25 files changed, 748 insertions(+), 122 deletions(-) create mode 100644 src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx create mode 100644 src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 035bc83fd..38a7460d3 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -125,6 +125,7 @@ "filter-combined": "Combined", "filter-raw": "Raw", "filters": "Filters", + "property-filter": "Property Filter", "first-click": "First click", "first-seen": "First seen", "funnel": "Funnel", diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx new file mode 100644 index 000000000..a424d595a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx @@ -0,0 +1,96 @@ +'use client'; +import { Button, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { X } from '@/components/icons'; +import type { EventPropertyFilter } from '@/lib/types'; + +export function EventDataFilterBar({ + filters, + onChange, +}: { + filters: EventPropertyFilter[]; + onChange: (filters: EventPropertyFilter[]) => void; +}) { + const { t, labels } = useMessages(); + + if (!filters.length) return null; + + const operatorLabel = (op: string) => { + switch (op) { + case 'eq': return t(labels.is); + case 'neq': return t(labels.isNot); + case 'c': return t(labels.contains); + case 'dnc': return t(labels.doesNotContain); + case 'regex': return t(labels.regexMatch); + case 'notRegex': return t(labels.regexNotMatch); + case 'gt': return t(labels.greaterThan); + case 'lt': return t(labels.lessThan); + case 'gte': return t(labels.greaterThanEquals); + case 'lte': return t(labels.lessThanEquals); + default: return op; + } + }; + + const handleRemove = (index: number) => { + onChange(filters.filter((_, i) => i !== index)); + }; + + return ( + + + {filters.map((filter, index) => ( + handleRemove(index)} + /> + ))} + + + + + {t(labels.clearAll)} + + + + ); +} + +function FilterPill({ + label, + operator, + value, + onRemove, +}: { + label: string; + operator: string; + value: string; + onRemove: () => void; +}) { + return ( + + + + {label} + + {operator} + + {value} + + + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx new file mode 100644 index 000000000..4f39b41e2 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx @@ -0,0 +1,34 @@ +'use client'; +import { useMessages } from '@/components/hooks'; +import { ListFilter } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import type { EventPropertyFilter } from '@/lib/types'; +import { EventDataFilterEditForm } from './EventDataFilterEditForm'; + +export function EventDataFilterButton({ + websiteId, + eventName, + eventFilters, + onApply, +}: { + websiteId: string; + eventName: string; + eventFilters: EventPropertyFilter[]; + onApply: (filters: EventPropertyFilter[]) => void; +}) { + const { t, labels } = useMessages(); + + return ( + } label={t(labels.propertyFilter)} variant="outline"> + {({ close }) => ( + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx new file mode 100644 index 000000000..82030cd32 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx @@ -0,0 +1,108 @@ +'use client'; +import { Button, Column, Grid, Icon, List, ListItem, Menu, MenuItem, MenuTrigger, Popover, Row } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useEventDataFieldsQuery, useMessages, useMobile } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import type { EventPropertyFilter } from '@/lib/types'; +import { EventDataFilterRecord } from './EventDataFilterRecord'; + +export function EventDataFilterEditForm({ + websiteId, + eventName, + value, + onApply, + onClose, +}: { + websiteId: string; + eventName: string; + value: EventPropertyFilter[]; + onApply: (filters: EventPropertyFilter[]) => void; + onClose: () => void; +}) { + const { t, labels, messages } = useMessages(); + const { isMobile } = useMobile(); + const [filters, setFilters] = useState(value); + + const { data: fields = [] } = useEventDataFieldsQuery(websiteId, eventName); + + const handleAdd = (propertyName: string) => { + const field = (fields as any[]).find(f => f.propertyName === propertyName); + const dataType: number = field?.dataType ?? 1; + setFilters(prev => [...prev, { propertyName, dataType, operator: 'eq', value: '' }]); + }; + + const handleChange = (index: number, filter: EventPropertyFilter) => { + setFilters(prev => prev.map((f, i) => (i === index ? filter : f))); + }; + + const handleRemove = (index: number) => { + setFilters(prev => prev.filter((_, i) => i !== index)); + }; + + return ( + + + + + + + + handleAdd(key.toString())} + style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} + > + {(fields as any[]).map(field => ( + + {field.propertyName} + + ))} + + + + + + handleAdd(key.toString())}> + {(fields as any[]).map(field => ( + + {field.propertyName} + + ))} + + + + {filters.map((filter, index) => ( + handleChange(index, f)} + onRemove={() => handleRemove(index)} + /> + ))} + {!filters.length && } + + + + + + + + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx new file mode 100644 index 000000000..4ca358029 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx @@ -0,0 +1,121 @@ +'use client'; +import { Button, Column, Grid, Icon, Label, ListItem, Loading, Select, TextField } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { MultiSelect } from '@/components/common/MultiSelect'; +import { useEventDataValuesQuery, useMessages } from '@/components/hooks'; +import { X } from '@/components/icons'; +import type { EventPropertyFilter } from '@/lib/types'; + +const STRING_OPERATORS = ['eq', 'neq', 'c', 'dnc', 'regex', 'notRegex'] as const; +const NUMERIC_OPERATORS = ['eq', 'neq', 'gt', 'lt', 'gte', 'lte'] as const; + +export function EventDataFilterRecord({ + websiteId, + eventName, + filter, + onChange, + onRemove, +}: { + websiteId: string; + eventName: string; + filter: EventPropertyFilter; + onChange: (filter: EventPropertyFilter) => void; + onRemove: () => void; +}) { + const { t, labels, messages } = useMessages(); + const [search, setSearch] = useState(''); + + const isNumeric = filter.dataType === 2; + const operators = isNumeric ? NUMERIC_OPERATORS : STRING_OPERATORS; + const isFreeText = filter.operator === 'c' || filter.operator === 'dnc' || filter.operator === 'regex' || filter.operator === 'notRegex'; + + const { data, isLoading } = useEventDataValuesQuery( + websiteId, + eventName, + filter.propertyName, + { enabled: !isNumeric && !isFreeText }, + ); + + const values = (data as Array<{ value: string }> | undefined)?.map(d => d.value) ?? []; + const filteredValues = search + ? values.filter(v => v.toLowerCase().includes(search.toLowerCase())) + : values; + const selected = filter.value ? filter.value.split(',').filter(Boolean) : []; + + const operatorLabel = (op: string) => { + switch (op) { + case 'eq': return t(labels.is); + case 'neq': return t(labels.isNot); + case 'c': return t(labels.contains); + case 'dnc': return t(labels.doesNotContain); + case 'regex': return t(labels.regexMatch); + case 'notRegex': return t(labels.regexNotMatch); + case 'gt': return t(labels.greaterThan); + case 'lt': return t(labels.lessThan); + case 'gte': return t(labels.greaterThanEquals); + case 'lte': return t(labels.lessThanEquals); + default: return op; + } + }; + + const handleOperatorChange = (op: string) => { + // clear value when switching between multi-select and free-text modes + const wasMulti = filter.operator === 'eq' || filter.operator === 'neq'; + const isMulti = op === 'eq' || op === 'neq'; + onChange({ ...filter, operator: op, value: wasMulti === isMulti ? filter.value : '' }); + }; + + return ( + + + + + + {isNumeric || isFreeText ? ( + onChange({ ...filter, value: v })} + inputMode={isNumeric ? 'numeric' : 'text'} + /> + ) : ( + onChange({ ...filter, value: vals.join(',') })} + searchValue={search} + onSearch={setSearch} + allowSearch + renderValue={vals => (vals.length > 0 ? vals.join(', ') : undefined)} + renderEmptyState={() => + isLoading ? ( + + ) : ( + + ) + } + > + {filteredValues.map(v => ( + + {v} + + ))} + + )} + + + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx index 09b559810..17ef799da 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx @@ -14,15 +14,18 @@ import { import { renderDateLabels } from '@/lib/charts'; import { getThemeColors } from '@/lib/colors'; import { generateTimeSeries } from '@/lib/date'; +import type { EventPropertyFilter } from '@/lib/types'; export function EventDataNumericChart({ websiteId, eventName, propertyName, + eventFilters = [], }: { websiteId: string; eventName: string; propertyName: string; + eventFilters?: EventPropertyFilter[]; }) { const { t, labels } = useMessages(); const { theme } = useTheme(); @@ -36,12 +39,14 @@ export function EventDataNumericChart({ eventName, propertyName, 'sum', + eventFilters, ); const avgQuery = useEventDataNumericSeriesQuery( websiteId, eventName, propertyName, 'avg', + eventFilters, ); const chartData: any = useMemo(() => { diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx index a0aad0e39..ac8b74dd2 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx @@ -8,20 +8,23 @@ import Link from '@/components/common/Link'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Pager } from '@/components/common/Pager'; import { useEventDataPivotQuery, useEventDataPropertiesQuery, useMessages, useMobile, useNavigation } from '@/components/hooks'; +import type { EventPropertyFilter } from '@/lib/types'; export function EventDataPivotTable({ websiteId, eventName, + eventFilters = [], }: { websiteId: string; eventName: string; + eventFilters?: EventPropertyFilter[]; }) { const { t, labels } = useMessages(); const { router, updateParams } = useNavigation(); const { isMobile } = useMobile(); const propertiesQuery = useEventDataPropertiesQuery(websiteId); - const pivotQuery = useEventDataPivotQuery(websiteId, eventName); + const pivotQuery = useEventDataPivotQuery(websiteId, eventName, eventFilters); const propertyKeys = useMemo(() => { if (!propertiesQuery.data || !eventName) return []; diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx index 5a7f8e5d1..431b87a6f 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx @@ -8,7 +8,6 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useDateRange, useEventDataPropertySeriesQuery, - useEventDataValuesQuery, useLocale, useMessages, useTimezone, @@ -17,26 +16,44 @@ import { ListTable } from '@/components/metrics/ListTable'; import { renderDateLabels } from '@/lib/charts'; import { CHART_COLORS } from '@/lib/constants'; import { generateTimeSeries } from '@/lib/date'; +import type { EventPropertyFilter } from '@/lib/types'; export function EventDataPropertyChart({ websiteId, eventName, propertyName, + eventFilters = [], }: { websiteId: string; eventName: string; propertyName: string; + eventFilters?: EventPropertyFilter[]; }) { const { t, labels } = useMessages(); const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone }); const { locale, dateLocale } = useLocale(); - const { data, isLoading, error } = useEventDataPropertySeriesQuery(websiteId, eventName, propertyName); - const valuesQuery = useEventDataValuesQuery(websiteId, eventName, propertyName); - const valueLabels = useMemo( - () => valuesQuery.data?.map(({ value }) => value) ?? [], - [valuesQuery.data], + const { data, isLoading, isFetching, error } = useEventDataPropertySeriesQuery( + websiteId, + eventName, + propertyName, + eventFilters, ); + + // Aggregate totals per value from the already-filtered time series + const aggregated = useMemo(() => { + if (!data) return []; + const totals = (data as any[]).reduce((obj: Record, { x, y }) => { + obj[x] = (obj[x] ?? 0) + y; + return obj; + }, {}); + return Object.entries(totals) + .map(([value, total]) => ({ value, total: total as number })) + .sort((a, b) => b.total - a.total); + }, [data]); + + const valueLabels = useMemo(() => aggregated.map(({ value }) => value), [aggregated]); + const colorMap = useMemo(() => { return valueLabels.reduce( (obj, label, index) => { @@ -50,13 +67,9 @@ export function EventDataPropertyChart({ const chartData: any = useMemo(() => { if (!data) return; - const map = (data as any[]).reduce((obj, { x, t, y }) => { - if (!obj[x]) { - obj[x] = []; - } - + const map = (data as any[]).reduce((obj: Record, { x, t, y }) => { + if (!obj[x]) obj[x] = []; obj[x].push({ x: t, y }); - return obj; }, {}); @@ -70,57 +83,57 @@ export function EventDataPropertyChart({ }, ], }; - } else { - const keys = [ - ...valueLabels.filter(label => map[label]), - ...Object.keys(map).filter(key => !valueLabels.includes(key)), - ]; - - return { - datasets: keys.map((key, index) => { - const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]); - return { - label: key, - data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), - lineTension: 0, - backgroundColor: color.alpha(0.6).toRgbString(), - borderColor: color.alpha(0.7).toRgbString(), - borderWidth: 1, - }; - }), - }; } + + const keys = [ + ...valueLabels.filter(label => map[label]), + ...Object.keys(map).filter(key => !valueLabels.includes(key)), + ]; + + return { + datasets: keys.map((key, index) => { + const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; }, [data, startDate, endDate, unit, dateLocale, valueLabels, colorMap]); const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); - const propertySum = useMemo(() => { - return valuesQuery.data?.reduce((sum, { total }) => sum + total, 0) ?? 0; - }, [valuesQuery.data]); + + const propertySum = useMemo( + () => aggregated.reduce((sum, { total }) => sum + total, 0), + [aggregated], + ); const tableData = useMemo(() => { - if (!valuesQuery.data || propertySum === 0) return []; - - return valuesQuery.data.map(({ value, total }) => ({ + if (!aggregated.length || propertySum === 0) return []; + return aggregated.map(({ value, total }) => ({ label: value, count: total, percent: 100 * (total / propertySum), })); - }, [valuesQuery.data, propertySum]); + }, [aggregated, propertySum]); const pieChartData: any = useMemo(() => { - if (!valuesQuery.data?.length) return null; - + if (!aggregated.length) return null; return { labels: valueLabels, datasets: [ { - data: valuesQuery.data.map(({ total }) => total), + data: aggregated.map(({ total }) => total), backgroundColor: valueLabels.map(label => colorMap[label]), borderWidth: 0, }, ], }; - }, [valuesQuery.data, valueLabels, colorMap]); + }, [aggregated, valueLabels, colorMap]); return ( @@ -139,9 +152,9 @@ export function EventDataPropertyChart({ diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx index 68a1cc5ab..5f3b68e5d 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx @@ -1,8 +1,11 @@ 'use client'; -import { Column, ComboBox, Grid, Label, ListItem, Select } from '@umami/react-zen'; +import { Column, ComboBox, Grid, Label, ListItem, Row, Select } from '@umami/react-zen'; import { useMemo, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useEventDataPropertiesQuery, useMessages } from '@/components/hooks'; +import type { EventPropertyFilter } from '@/lib/types'; +import { EventDataFilterBar } from '../event-data/EventDataFilterBar'; +import { EventDataFilterButton } from '../event-data/EventDataFilterButton'; import { EventDataNumericChart } from '../event-data/EventDataNumericChart'; import { EventDataPivotTable } from '../event-data/EventDataPivotTable'; import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart'; @@ -10,6 +13,7 @@ import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart'; export function EventProperties({ websiteId }: { websiteId: string }) { const [eventName, setEventName] = useState(''); const [propertyName, setPropertyName] = useState(''); + const [eventFilters, setEventFilters] = useState([]); const { t, labels } = useMessages(); const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId); @@ -45,6 +49,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) { const handleEventChange = (value: string) => { setEventName(value); setPropertyName(''); + setEventFilters([]); }; return ( @@ -57,39 +62,52 @@ export function EventProperties({ websiteId }: { websiteId: string }) { > {data && ( - - - - - - - - - {properties.map((field: { propertyName: string }) => ( - - {field.propertyName} - - ))} - - - + + + + + + + + + + {properties.map((field: { propertyName: string }) => ( + + {field.propertyName} + + ))} + + + + {eventName && ( + + )} + + )} + {eventName && ( + )} {eventName && propertyName && ( @@ -98,6 +116,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) { websiteId={websiteId} eventName={eventName} propertyName={propertyName} + eventFilters={eventFilters} /> )} {selectedProperty?.dataType === 2 && ( @@ -105,11 +124,18 @@ export function EventProperties({ websiteId }: { websiteId: string }) { websiteId={websiteId} eventName={eventName} propertyName={propertyName} + eventFilters={eventFilters} /> )} )} - {eventName && } + {eventName && ( + + )} ); diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts index 37e1047f4..048fe7c5c 100644 --- a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts +++ b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseEventPropertyFilters } from '@/lib/params'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams } from '@/lib/schema'; @@ -32,7 +33,8 @@ export async function GET( const { eventName, propertyName, metric, ...rest } = query; const filters = await getQueryFilters(rest, websiteId); - const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters); + const eventFilters = parseEventPropertyFilters(query); + const data = await getEventDataNumericSeries(websiteId, eventName, propertyName, metric, filters, eventFilters); return json(data); } diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts index 7e923689f..4a60edbef 100644 --- a/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts +++ b/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseEventPropertyFilters } from '@/lib/params'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams } from '@/lib/schema'; @@ -31,7 +32,8 @@ export async function GET( const { eventName, propertyName, ...rest } = query; const filters = await getQueryFilters(rest, websiteId); - const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters); + const eventFilters = parseEventPropertyFilters(query); + const data = await getEventDataPropertySeries(websiteId, eventName, propertyName, filters, eventFilters); return json(data); } diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/route.ts index 37216f6e3..c43aa9f3f 100644 --- a/src/app/api/websites/[websiteId]/event-data-pivot/route.ts +++ b/src/app/api/websites/[websiteId]/event-data-pivot/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseEventPropertyFilters } from '@/lib/params'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams, pagingParams } from '@/lib/schema'; @@ -31,7 +32,8 @@ export async function GET( const { eventName, ...rest } = query; const filters = await getQueryFilters(rest, websiteId); - const result = await getEventDataPivot(websiteId, eventName, filters); + const eventFilters = parseEventPropertyFilters(query); + const result = await getEventDataPivot(websiteId, eventName, filters, eventFilters); return json(result); } diff --git a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts index eb8b63638..d02674e89 100644 --- a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts +++ b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts @@ -1,4 +1,5 @@ -import type { ReactQueryOptions } from '@/lib/types'; +import { serializeEventPropertyFilters } from '@/lib/params'; +import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; import { useFilterParameters } from '../useFilterParameters'; @@ -8,6 +9,7 @@ export function useEventDataNumericSeriesQuery( eventName: string, propertyName: string, metric: 'sum' | 'avg', + eventFilters: EventPropertyFilter[] = [], options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); @@ -17,7 +19,7 @@ export function useEventDataNumericSeriesQuery( return useQuery({ queryKey: [ 'websites:event-data-pivot:numeric-series', - { websiteId, eventName, propertyName, metric, startAt, endAt, unit, timezone, ...params }, + { websiteId, eventName, propertyName, metric, eventFilters, startAt, endAt, unit, timezone, ...params }, ], queryFn: () => get(`/websites/${websiteId}/event-data-pivot/numeric-series`, { @@ -28,6 +30,7 @@ export function useEventDataNumericSeriesQuery( endAt, unit, timezone, + ...serializeEventPropertyFilters(eventFilters), ...params, }), enabled: !!(websiteId && eventName && propertyName), diff --git a/src/components/hooks/queries/useEventDataPivotQuery.ts b/src/components/hooks/queries/useEventDataPivotQuery.ts index e805c2b91..e9f689ac7 100644 --- a/src/components/hooks/queries/useEventDataPivotQuery.ts +++ b/src/components/hooks/queries/useEventDataPivotQuery.ts @@ -1,4 +1,5 @@ -import type { ReactQueryOptions } from '@/lib/types'; +import { serializeEventPropertyFilters } from '@/lib/params'; +import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; import { useFilterParameters } from '../useFilterParameters'; @@ -6,6 +7,7 @@ import { useFilterParameters } from '../useFilterParameters'; export function useEventDataPivotQuery( websiteId: string, eventName: string, + eventFilters: EventPropertyFilter[] = [], options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); @@ -15,7 +17,7 @@ export function useEventDataPivotQuery( return useQuery({ queryKey: [ 'websites:event-data-pivot', - { websiteId, eventName, startAt, endAt, unit, timezone, ...params }, + { websiteId, eventName, eventFilters, startAt, endAt, unit, timezone, ...params }, ], queryFn: () => get(`/websites/${websiteId}/event-data-pivot`, { @@ -24,6 +26,7 @@ export function useEventDataPivotQuery( endAt, unit, timezone, + ...serializeEventPropertyFilters(eventFilters), ...params, }), enabled: !!(websiteId && eventName), diff --git a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts index a60f80442..68d963ba8 100644 --- a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts +++ b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts @@ -1,4 +1,5 @@ -import type { ReactQueryOptions } from '@/lib/types'; +import { serializeEventPropertyFilters } from '@/lib/params'; +import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; import { useFilterParameters } from '../useFilterParameters'; @@ -7,6 +8,7 @@ export function useEventDataPropertySeriesQuery( websiteId: string, eventName: string, propertyName: string, + eventFilters: EventPropertyFilter[] = [], options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); @@ -16,7 +18,7 @@ export function useEventDataPropertySeriesQuery( return useQuery({ queryKey: [ 'websites:event-data-pivot:property-series', - { websiteId, eventName, propertyName, startAt, endAt, unit, timezone, ...params }, + { websiteId, eventName, propertyName, eventFilters, startAt, endAt, unit, timezone, ...params }, ], queryFn: () => get(`/websites/${websiteId}/event-data-pivot/property-series`, { @@ -26,6 +28,7 @@ export function useEventDataPropertySeriesQuery( endAt, unit, timezone, + ...serializeEventPropertyFilters(eventFilters), ...params, }), enabled: !!(websiteId && eventName && propertyName), diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx index 90be256be..c5d6e3c10 100644 --- a/src/components/input/FilterBar.tsx +++ b/src/components/input/FilterBar.tsx @@ -58,8 +58,9 @@ export function FilterBar({ websiteId }: { websiteId?: string }) { justifyContent="space-between" padding="2" backgroundColor="surface-sunken" + wrap="wrap" > - + {segment && !isLoading && ( { theme="dark" > - + {label} diff --git a/src/components/messages.ts b/src/components/messages.ts index d5d77c206..79adab814 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -190,6 +190,7 @@ export const labels: Record = { type: 'label.type', filter: 'label.filter', filters: 'label.filters', + propertyFilter: 'label.property-filter', breakdown: 'label.breakdown', true: 'label.true', false: 'label.false', diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index 0a8739b6c..5f3bedd0a 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -1,10 +1,10 @@ +import { CLICKHOUSE } from '@/lib/db'; import { type ClickHouseClient, createClient } from '@clickhouse/client'; import { formatInTimeZone } from 'date-fns-tz'; import debug from 'debug'; -import { CLICKHOUSE } from '@/lib/db'; import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants'; import { filtersObjectToArray } from './params'; -import type { QueryFilters, QueryOptions } from './types'; +import type { EventPropertyFilter, QueryFilters, QueryOptions } from './types'; export const CLICKHOUSE_DATE_FORMATS = { utc: '%Y-%m-%dT%H:%i:%SZ', @@ -235,6 +235,79 @@ function parseFilters(filters: Record, options?: QueryOptions) { }; } +function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): { + sql: string; + params: Record; +} { + if (!filters.length) return { sql: '', params: {} }; + + const parts: string[] = []; + const params: Record = {}; + + filters.forEach(({ propertyName, dataType, operator, value }, i) => { + const keyParam = `epf_key_${i}`; + const valParam = `epf_val_${i}`; + params[keyParam] = propertyName; + + const isNumeric = dataType === 2; + const col = isNumeric ? 'number_value' : 'string_value'; + + let condition: string; + if (isNumeric) { + params[valParam] = parseFloat(value) || 0; + const opMap: Record = { + eq: `${col} = {${valParam}:Float64}`, + neq: `${col} != {${valParam}:Float64}`, + gt: `${col} > {${valParam}:Float64}`, + lt: `${col} < {${valParam}:Float64}`, + gte: `${col} >= {${valParam}:Float64}`, + lte: `${col} <= {${valParam}:Float64}`, + }; + condition = opMap[operator] ?? `${col} = {${valParam}:Float64}`; + } else if (operator === 'eq' || operator === 'neq') { + const vals = value.split(',').filter(Boolean); + if (!vals.length) return; + params[valParam] = vals; + condition = mapFilter( + col, + operator === 'eq' ? OPERATORS.equals : OPERATORS.notEquals, + valParam, + 'String', + ); + } else if (operator === 'regex' || operator === 'notRegex') { + if (!value) return; + params[valParam] = value; + condition = mapFilter( + col, + operator === 'regex' ? OPERATORS.regex : OPERATORS.notRegex, + valParam, + 'String', + ); + } else { + if (!value) return; + params[valParam] = value; + condition = mapFilter( + col, + operator === 'c' ? OPERATORS.contains : OPERATORS.doesNotContain, + valParam, + 'String', + ); + } + + parts.push(`and event_id in ( + select event_id + from event_data + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and data_key = {${keyParam}:String} + and data_type = ${dataType} + and ${condition} + )`); + }); + + return { sql: parts.join('\n'), params }; +} + async function pagedRawQuery( query: string, queryParams: Record, @@ -321,6 +394,7 @@ export default { getDateSQL, getSearchSQL, getFilterQuery, + getEventPropertyFilterQuery, getUTCString, parseFilters, pagedRawQuery, diff --git a/src/lib/params.ts b/src/lib/params.ts index f1452254d..60c563f10 100644 --- a/src/lib/params.ts +++ b/src/lib/params.ts @@ -1,5 +1,5 @@ import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; -import type { Filter, QueryFilters, QueryOptions } from '@/lib/types'; +import type { EventPropertyFilter, Filter, QueryFilters, QueryOptions } from '@/lib/types'; export function parseFilterValue(param: any) { if (typeof param === 'string') { @@ -88,3 +88,31 @@ export function filtersArrayToObject(filters: Filter[]) { return obj; }, {}); } + +export function parseEventPropertyFilters(query: Record): EventPropertyFilter[] { + return Object.entries(query) + .filter(([key]) => /^epf_/.test(key)) + .flatMap(([key, val]) => { + const dotIndex = (val as string).indexOf('.'); + if (dotIndex < 1) return []; + const withoutPrefix = key.slice(4); // strip "epf_" + const propertyName = withoutPrefix.replace(/\d+$/, ''); // strip trailing index digits + const operator = (val as string).slice(0, dotIndex); + const value = (val as string).slice(dotIndex + 1); + const isNumeric = + ['gt', 'lt', 'gte', 'lte'].includes(operator) || + (['eq', 'neq'].includes(operator) && value !== '' && !Number.isNaN(Number(value))); + return [{ propertyName, dataType: isNumeric ? 2 : 1, operator, value }]; + }); +} + +export function serializeEventPropertyFilters(filters: EventPropertyFilter[]): Record { + const counts: Record = {}; + return Object.fromEntries( + filters.map(f => { + const n = counts[f.propertyName] ?? 0; + counts[f.propertyName] = n + 1; + return [`epf_${f.propertyName}${n > 0 ? n : ''}`, `${f.operator}.${f.value}`]; + }), + ); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index c3b6aaca3..266f11455 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,10 +1,10 @@ +import { PrismaClient } from '@/generated/prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import { readReplicas } from '@prisma/extension-read-replicas'; import debug from 'debug'; -import { PrismaClient } from '@/generated/prisma/client'; import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants'; import { filtersObjectToArray } from './params'; -import type { Operator, QueryFilters, QueryOptions } from './types'; +import type { EventPropertyFilter, Operator, QueryFilters, QueryOptions } from './types'; const log = debug('umami:prisma'); @@ -252,6 +252,68 @@ function parseFilters(filters: Record, options?: QueryOptions) { }; } +function getEventPropertyFilterQuery(filters: EventPropertyFilter[] = []): { + sql: string; + params: Record; +} { + if (!filters.length) return { sql: '', params: {} }; + + const parts: string[] = []; + const params: Record = {}; + + filters.forEach(({ propertyName, dataType, operator, value }, i) => { + const keyParam = `epf_key_${i}`; + const valParam = `epf_val_${i}`; + params[keyParam] = propertyName; + + const isNumeric = dataType === 2; + const col = isNumeric ? 'cast(number_value as decimal)' : 'string_value'; + + let condition: string; + if (isNumeric) { + params[valParam] = parseFloat(value) || 0; + const opMap: Record = { + eq: `${col} = {{${valParam}}}`, + neq: `${col} != {{${valParam}}}`, + gt: `${col} > {{${valParam}}}`, + lt: `${col} < {{${valParam}}}`, + gte: `${col} >= {{${valParam}}}`, + lte: `${col} <= {{${valParam}}}`, + }; + condition = opMap[operator] ?? `${col} = {{${valParam}}}`; + } else if (operator === 'eq' || operator === 'neq') { + const vals = value.split(',').filter(Boolean); + if (!vals.length) return; + params[valParam] = vals; + condition = + operator === 'eq' + ? `${col} = ANY({{${valParam}::text[]}})` + : `${col} != ALL({{${valParam}::text[]}})`; + } else if (operator === 'regex' || operator === 'notRegex') { + if (!value) return; + params[valParam] = value; + condition = operator === 'regex' ? `${col} ~* {{${valParam}}}` : `${col} !~* {{${valParam}}}`; + } else { + if (!value) return; + params[valParam] = `%${value}%`; + condition = + operator === 'c' ? `${col} ilike {{${valParam}}}` : `${col} not ilike {{${valParam}}}`; + } + + parts.push(`and website_event.event_id in ( + select website_event_id + from event_data + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and data_key = {{${keyParam}}} + and data_type = ${dataType} + and ${condition} + )`); + }); + + return { sql: parts.join('\n'), params }; +} + async function rawQuery(sql: string, data: Record, name?: string): Promise { if (process.env.LOG_QUERY) { log('QUERY:\n', sql); @@ -426,6 +488,7 @@ export default { getDateSQL, getDateWeeklySQL, getFilterQuery, + getEventPropertyFilterQuery, getSearchParameters, getTimestampDiffSQL, getSearchSQL, diff --git a/src/lib/request.ts b/src/lib/request.ts index 69f6eb1d7..40ffabe06 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,5 +1,3 @@ -import { startOfMonth, subMonths } from 'date-fns'; -import { z } from 'zod'; import { checkAuth } from '@/lib/auth'; import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date'; @@ -8,6 +6,8 @@ import { filtersArrayToObject } from '@/lib/params'; import { badRequest, unauthorized } from '@/lib/response'; import type { QueryFilters } from '@/lib/types'; import { getWebsiteSegment } from '@/queries/prisma'; +import { startOfMonth, subMonths } from 'date-fns'; +import { z } from 'zod'; export async function parseRequest( request: Request, @@ -30,9 +30,9 @@ export async function parseRequest( } else if (isGet) { query = result.data; - // Re-add suffixed filter params (e.g., browser1, os2) stripped by Zod schema + // Re-add dynamic params stripped by Zod schema: suffixed filter params (browser1, os2) for (const key of Object.keys(rawQuery)) { - if (/\d+$/.test(key) && !(key in query)) { + if ((/\d+$/.test(key) || /^epf_/.test(key)) && !(key in query)) { query[key] = rawQuery[key]; } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 72104352e..7569f2821 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -30,6 +30,13 @@ export interface Auth { }; } +export interface EventPropertyFilter { + propertyName: string; + dataType: number; + operator: string; + value: string; +} + export interface Filter { name: string; operator: Operator; diff --git a/src/queries/sql/events/getEventDataNumericSeries.ts b/src/queries/sql/events/getEventDataNumericSeries.ts index 5b0ef85d2..64bd9b66e 100644 --- a/src/queries/sql/events/getEventDataNumericSeries.ts +++ b/src/queries/sql/events/getEventDataNumericSeries.ts @@ -1,7 +1,7 @@ import clickhouse from '@/lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; -import type { QueryFilters } from '@/lib/types'; +import type { EventPropertyFilter, QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getEventDataNumericSeries'; @@ -12,6 +12,7 @@ export async function getEventDataNumericSeries( propertyName: string, metric: 'sum' | 'avg', filters: QueryFilters, + eventFilters?: EventPropertyFilter[], ] ) { return runQuery({ @@ -26,13 +27,15 @@ async function relationalQuery( propertyName: string, metric: 'sum' | 'avg', filters: QueryFilters, + eventFilters: EventPropertyFilter[] = [], ) { const { timezone = 'utc', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters } = prisma; + const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, }); + const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters); const aggFn = metric === 'avg' ? 'avg' : 'sum'; return rawQuery( @@ -53,10 +56,11 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type = 2 ${filterQuery} + ${epfSQL} group by 1 order by 1 `, - { ...queryParams, eventName, propertyName }, + { ...queryParams, eventName, propertyName, ...epfParams }, FUNCTION_NAME, ); } @@ -67,10 +71,12 @@ async function clickhouseQuery( propertyName: string, metric: 'sum' | 'avg', filters: QueryFilters, + eventFilters: EventPropertyFilter[] = [], ): Promise<{ t: string; y: number }[]> { const { timezone = 'UTC', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters } = clickhouse; + const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters); const aggFn = metric === 'avg' ? 'avg' : 'sum'; return rawQuery( @@ -95,10 +101,11 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type = 2 ${filterQuery} + ${epfSQL} group by t order by t `, - { ...queryParams, eventName, propertyName }, + { ...queryParams, eventName, propertyName, ...epfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/events/getEventDataPivot.ts b/src/queries/sql/events/getEventDataPivot.ts index eb69fa410..00044b649 100644 --- a/src/queries/sql/events/getEventDataPivot.ts +++ b/src/queries/sql/events/getEventDataPivot.ts @@ -2,12 +2,17 @@ import clickhouse from '@/lib/clickhouse'; import { DEFAULT_PAGE_SIZE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; -import type { QueryFilters } from '@/lib/types'; +import type { EventPropertyFilter, QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getEventDataPivot'; export async function getEventDataPivot( - ...args: [websiteId: string, eventName: string, filters: QueryFilters] + ...args: [ + websiteId: string, + eventName: string, + filters: QueryFilters, + eventFilters?: EventPropertyFilter[], + ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -15,8 +20,8 @@ export async function getEventDataPivot( }); } -async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters) { - const { rawQuery, parseFilters } = prisma; +async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) { + const { rawQuery, parseFilters, getEventPropertyFilterQuery } = prisma; const { page = 1, pageSize } = filters; const size = +pageSize || DEFAULT_PAGE_SIZE; const offset = +size * (+page - 1); @@ -25,6 +30,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu ...filters, websiteId, }); + const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters); const countResult = await rawQuery( ` @@ -39,8 +45,9 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_name = {{eventName}} ${filterQuery} + ${epfSQL} `, - { ...queryParams, eventName }, + { ...queryParams, eventName, ...epfParams }, ); const count = countResult[0].num; @@ -59,6 +66,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_name = {{eventName}} ${filterQuery} + ${epfSQL} group by website_event.event_id order by max(website_event.created_at) desc limit ${size} offset ${offset} @@ -85,7 +93,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu and event_data.created_at between {{startDate}} and {{endDate}} order by website_event.created_at desc `, - { ...queryParams, eventName }, + { ...queryParams, eventName, ...epfParams }, FUNCTION_NAME, ); @@ -106,13 +114,14 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu return { data: [...eventMap.values()], count, page: +page, pageSize: size }; } -async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters) { - const { rawQuery, parseFilters } = clickhouse; +async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) { + const { rawQuery, parseFilters, getEventPropertyFilterQuery } = clickhouse; const { page = 1, pageSize } = filters; const size = +pageSize || DEFAULT_PAGE_SIZE; const offset = +size * (+page - 1); const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters); const count = await rawQuery( ` @@ -132,8 +141,9 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu where event_data_pivot.website_id = {websiteId:UUID} and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} + ${epfSQL} `, - { ...queryParams, eventName }, + { ...queryParams, eventName, ...epfParams }, ).then((res: any) => res[0].num); const data = await rawQuery( @@ -161,11 +171,12 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu where event_data_pivot.website_id = {websiteId:UUID} and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} + ${epfSQL} group by event_id, session_id, event_name, url_path, created_at order by created_at desc limit ${size} offset ${offset} `, - { ...queryParams, eventName }, + { ...queryParams, eventName, ...epfParams }, FUNCTION_NAME, ); diff --git a/src/queries/sql/events/getEventDataPropertySeries.ts b/src/queries/sql/events/getEventDataPropertySeries.ts index deb1c65b9..c8ef193dc 100644 --- a/src/queries/sql/events/getEventDataPropertySeries.ts +++ b/src/queries/sql/events/getEventDataPropertySeries.ts @@ -1,12 +1,18 @@ import clickhouse from '@/lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; -import type { QueryFilters } from '@/lib/types'; +import type { EventPropertyFilter, QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getEventDataPropertySeries'; export async function getEventDataPropertySeries( - ...args: [websiteId: string, eventName: string, propertyName: string, filters: QueryFilters] + ...args: [ + websiteId: string, + eventName: string, + propertyName: string, + filters: QueryFilters, + eventFilters?: EventPropertyFilter[], + ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -19,13 +25,15 @@ async function relationalQuery( eventName: string, propertyName: string, filters: QueryFilters, + eventFilters: EventPropertyFilter[] = [], ) { const { timezone = 'utc', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters } = prisma; + const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, }); + const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters); return rawQuery( ` @@ -46,10 +54,11 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type = 1 ${filterQuery} + ${epfSQL} group by 1, 2 order by 2 `, - { ...queryParams, eventName, propertyName }, + { ...queryParams, eventName, propertyName, ...epfParams }, FUNCTION_NAME, ); } @@ -59,10 +68,12 @@ async function clickhouseQuery( eventName: string, propertyName: string, filters: QueryFilters, + eventFilters: EventPropertyFilter[] = [], ): Promise<{ x: string; t: string; y: number }[]> { const { timezone = 'UTC', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters } = clickhouse; + const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters); return rawQuery( ` @@ -87,10 +98,11 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type = 1 ${filterQuery} + ${epfSQL} group by x, t order by t `, - { ...queryParams, eventName, propertyName }, + { ...queryParams, eventName, propertyName, ...epfParams }, FUNCTION_NAME, ); }