diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/queries/sql/reports | |
| download | umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.tar.xz umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/queries/sql/reports')
| -rw-r--r-- | src/queries/sql/reports/getAttribution.ts | 514 | ||||
| -rw-r--r-- | src/queries/sql/reports/getBreakdown.ts | 135 | ||||
| -rw-r--r-- | src/queries/sql/reports/getFunnel.ts | 255 | ||||
| -rw-r--r-- | src/queries/sql/reports/getGoal.ts | 105 | ||||
| -rw-r--r-- | src/queries/sql/reports/getJourney.ts | 275 | ||||
| -rw-r--r-- | src/queries/sql/reports/getRetention.ts | 173 | ||||
| -rw-r--r-- | src/queries/sql/reports/getRevenue.ts | 217 | ||||
| -rw-r--r-- | src/queries/sql/reports/getUTM.ts | 84 |
8 files changed, 1758 insertions, 0 deletions
diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts new file mode 100644 index 0000000..1d04078 --- /dev/null +++ b/src/queries/sql/reports/getAttribution.ts @@ -0,0 +1,514 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface AttributionParameters { + startDate: Date; + endDate: Date; + model: string; + type: string; + step: string; + currency?: string; +} + +export interface AttributionResult { + referrer: { name: string; value: number }[]; + paidAds: { name: string; value: number }[]; + utm_source: { name: string; value: number }[]; + utm_medium: { name: string; value: number }[]; + utm_campaign: { name: string; value: number }[]; + utm_content: { name: string; value: number }[]; + utm_term: { name: string; value: number }[]; + total: { pageviews: number; visitors: number; visits: number }; +} + +export async function getAttribution( + ...args: [websiteId: string, parameters: AttributionParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: AttributionParameters, + filters: QueryFilters, +): Promise<AttributionResult> { + const { model, type, currency } = parameters; + const { rawQuery, parseFilters } = prisma; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + ...parameters, + websiteId, + eventType, + }); + + function getUTMQuery(utmColumn: string) { + return ` + select + coalesce(we.${utmColumn}, '') name, + ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + ${currency ? '' : `and we.${utmColumn} != ''`} + group by 1 + order by 2 desc + limit 20`; + } + + const eventQuery = `WITH events AS ( + select distinct + website_event.session_id, + max(website_event.created_at) max_dt + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.${column} = {{step}} + ${filterQuery} + group by 1),`; + + const revenueEventQuery = `WITH events AS ( + select + revenue.session_id, + max(revenue.created_at) max_dt, + sum(revenue.revenue) value + from revenue + join website_event + on website_event.website_id = revenue.website_id + and website_event.session_id = revenue.session_id + and website_event.event_id = revenue.event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.${column} = {{step}} + and revenue.currency = {{currency}} + ${filterQuery} + group by 1),`; + + function getModelQuery(model: string) { + return model === 'first-click' + ? `\n + model AS (select e.session_id, + min(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + group by e.session_id)` + : `\n + model AS (select e.session_id, + max(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + and we.created_at < e.max_dt + group by e.session_id)`; + } + + const referrerRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + select coalesce(we.referrer_domain, '') name, + ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + join session s + on s.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + ${ + currency + ? '' + : `and we.referrer_domain != hostname + and we.referrer_domain != ''` + } + group by 1 + order by 2 desc + limit 20 + `, + queryParams, + ); + + const paidAdsres = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)}, + + results AS ( + select case + when coalesce(gclid, '') != '' then 'Google Ads' + when coalesce(fbclid, '') != '' then 'Facebook / Meta' + when coalesce(msclkid, '') != '' then 'Microsoft Ads' + when coalesce(ttclid, '') != '' then 'TikTok Ads' + when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads' + when coalesce(twclid, '') != '' then 'Twitter Ads (X)' + else '' + end name, + ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + group by 1 + order by 2 desc + limit 20) + SELECT * + FROM results + ${currency ? '' : `WHERE name != ''`} + `, + queryParams, + ); + + const sourceRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_source')} + `, + queryParams, + ); + + const mediumRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_medium')} + `, + queryParams, + ); + + const campaignRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_campaign')} + `, + queryParams, + ); + + const contentRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_content')} + `, + queryParams, + ); + + const termRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_term')} + `, + queryParams, + ); + + const totalRes = await rawQuery( + ` + select + count(*) as "pageviews", + count(distinct website_event.session_id) as "visitors", + count(distinct website_event.visit_id) as "visits" + from website_event + ${joinSessionQuery} + ${cohortQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.${column} = {{step}} + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + return { + referrer: referrerRes, + paidAds: paidAdsres, + utm_source: sourceRes, + utm_medium: mediumRes, + utm_campaign: campaignRes, + utm_content: contentRes, + utm_term: termRes, + total: totalRes, + }; +} + +async function clickhouseQuery( + websiteId: string, + parameters: AttributionParameters, + filters: QueryFilters, +): Promise<AttributionResult> { + const { model, type, currency } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + ...parameters, + websiteId, + eventType, + }); + + function getUTMQuery(utmColumn: string) { + return ` + select + we.${utmColumn} name, + ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${currency ? '' : `and we.${utmColumn} != ''`} + group by 1 + order by 2 desc + limit 20 + `; + } + + function getModelQuery(model: string) { + if (model === 'first-click') { + return ` + model AS (select e.session_id, + min(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by e.session_id) + `; + } + + return ` + model AS (select e.session_id, + max(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and we.created_at < e.max_dt + group by e.session_id) + `; + } + + const eventQuery = `WITH events AS ( + select distinct + session_id, + max(created_at) max_dt + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and ${column} = {step:String} + ${filterQuery} + group by 1),`; + + const revenueEventQuery = `WITH events AS ( + select + website_revenue.session_id, + max(website_revenue.created_at) max_dt, + sum(website_revenue.revenue) as value + from website_revenue + join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.${column} = {step:String} + and website_revenue.currency = {currency:String} + ${filterQuery} + group by 1),`; + + const referrerRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + select we.referrer_domain name, + ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${ + currency + ? '' + : `and we.referrer_domain != hostname + and we.referrer_domain != ''` + } + group by 1 + order by 2 desc + limit 20 + `, + queryParams, + ); + + const paidAdsres = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + select multiIf(gclid != '', 'Google Ads', + fbclid != '', 'Facebook / Meta', + msclkid != '', 'Microsoft Ads', + ttclid != '', 'TikTok Ads', + li_fat_id != '', 'LinkedIn Ads', + twclid != '', 'Twitter Ads (X)','') name, + ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${currency ? '' : `and name != ''`} + group by 1 + order by 2 desc + limit 20 + `, + queryParams, + ); + + const sourceRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_source')} + `, + queryParams, + ); + + const mediumRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_medium')} + `, + queryParams, + ); + + const campaignRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_campaign')} + `, + queryParams, + ); + + const contentRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_content')} + `, + queryParams, + ); + + const termRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_term')} + `, + queryParams, + ); + + const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>( + ` + select + count(*) as "pageviews", + uniqExact(session_id) as "visitors", + uniqExact(visit_id) as "visits" + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and ${column} = {step:String} + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + return { + referrer: referrerRes, + paidAds: paidAdsres, + utm_source: sourceRes, + utm_medium: mediumRes, + utm_campaign: campaignRes, + utm_content: contentRes, + utm_term: termRes, + total: totalRes, + }; +} diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts new file mode 100644 index 0000000..51773d8 --- /dev/null +++ b/src/queries/sql/reports/getBreakdown.ts @@ -0,0 +1,135 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface BreakdownParameters { + startDate: Date; + endDate: Date; + fields: string[]; +} + +export interface BreakdownData { + x: string; + y: number; +} + +export async function getBreakdown( + ...args: [websiteId: string, parameters: BreakdownParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: BreakdownParameters, + filters: QueryFilters, +): Promise<BreakdownData[]> { + const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; + const { startDate, endDate, fields } = parameters; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }, + { + joinSession: !!fields.find((name: string) => SESSION_COLUMNS.includes(name)), + }, + ); + + return rawQuery( + ` + select + sum(t.c) as "views", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime", + ${parseFieldsByName(fields)} + from ( + select + ${parseFields(fields)}, + website_event.session_id, + website_event.visit_id, + count(*) as "c", + min(website_event.created_at) as "min_time", + max(website_event.created_at) as "max_time" + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by ${parseFieldsByName(fields)}, + website_event.session_id, website_event.visit_id + ) as t + group by ${parseFieldsByName(fields)} + order by 1 desc, 2 desc + limit 500 + `, + queryParams, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: BreakdownParameters, + filters: QueryFilters, +): Promise<BreakdownData[]> { + const { parseFilters, rawQuery } = clickhouse; + const { startDate, endDate, fields } = parameters; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }); + + return rawQuery( + ` + select + sum(t.c) as "views", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime", + ${parseFieldsByName(fields)} + from ( + select + ${parseFields(fields)}, + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by ${parseFieldsByName(fields)}, + session_id, visit_id + ) as t + group by ${parseFieldsByName(fields)} + order by 1 desc, 2 desc + limit 500 + `, + queryParams, + ); +} + +function parseFields(fields: string[]) { + return fields.map(name => `${FILTER_COLUMNS[name]} as "${name}"`).join(','); +} + +function parseFieldsByName(fields: string[]) { + return `${fields.map(name => name).join(',')}`; +} diff --git a/src/queries/sql/reports/getFunnel.ts b/src/queries/sql/reports/getFunnel.ts new file mode 100644 index 0000000..4840123 --- /dev/null +++ b/src/queries/sql/reports/getFunnel.ts @@ -0,0 +1,255 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface FunnelParameters { + startDate: Date; + endDate: Date; + window: number; + steps: { type: string; value: string }[]; +} + +export interface FunnelResult { + value: string; + visitors: number; + dropoff: number; +} + +export async function getFunnel( + ...args: [websiteId: string, parameters: FunnelParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: FunnelParameters, + filters: QueryFilters, +): Promise<FunnelResult[]> { + const { startDate, endDate, window, steps } = parameters; + const { rawQuery, getAddIntervalQuery, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, window); + + function getFunnelQuery( + steps: { type: string; value: string }[], + window: number, + ): { + levelOneQuery: string; + levelQuery: string; + sumQuery: string; + params: string[]; + } { + return steps.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union ' : ''; + const isURL = cv.type === 'path'; + const column = isURL ? 'url_path' : 'event_name'; + + let operator = '='; + let paramValue = cv.value; + + if (cv.value.startsWith('*') || cv.value.endsWith('*')) { + operator = 'like'; + paramValue = cv.value.replace(/^\*|\*$/g, '%'); + } + + if (levelNumber === 1) { + pv.levelOneQuery = ` + WITH level1 AS ( + select distinct website_event.session_id, website_event.created_at + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and ${column} ${operator} {{${i}}} + ${filterQuery} + )`; + } else { + pv.levelQuery += ` + , level${levelNumber} AS ( + select distinct we.session_id, we.created_at + from level${i} l + join website_event we + on l.session_id = we.session_id + where we.website_id = {{websiteId::uuid}} + and we.created_at between l.created_at and ${getAddIntervalQuery( + `l.created_at `, + `${window} minute`, + )} + and we.${column} ${operator} {{${i}}} + and we.created_at <= {{endDate}} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + pv.params.push(paramValue); + + return pv; + }, + { + levelOneQuery: '', + levelQuery: '', + sumQuery: '', + params: [], + }, + ); + } + + return rawQuery( + ` + ${levelOneQuery} + ${levelQuery} + ${sumQuery} + ORDER BY level; + `, + { + ...params, + ...queryParams, + }, + ).then(formatResults(steps)); +} + +async function clickhouseQuery( + websiteId: string, + parameters: FunnelParameters, + filters: QueryFilters, +): Promise< + { + value: string; + visitors: number; + dropoff: number; + }[] +> { + const { startDate, endDate, window, steps } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery( + steps, + window, + ); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + + function getFunnelQuery( + steps: { type: string; value: string }[], + window: number, + ): { + levelOneQuery: string; + levelQuery: string; + sumQuery: string; + stepFilterQuery: string; + params: Record<string, string>; + } { + return steps.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union all ' : ''; + const startFilter = i > 0 ? 'or' : ''; + const isURL = cv.type === 'path'; + const column = isURL ? 'url_path' : 'event_name'; + + let operator = '='; + let paramValue = cv.value; + + if (cv.value.startsWith('*') || cv.value.endsWith('*')) { + operator = 'like'; + paramValue = cv.value.replace(/^\*|\*$/g, '%'); + } + + if (levelNumber === 1) { + pv.levelOneQuery = `\n + level1 AS ( + select * + from level0 + where ${column} ${operator} {param${i}:String} + )`; + } else { + pv.levelQuery += `\n + , level${levelNumber} AS ( + select distinct y.session_id as session_id, + y.url_path as url_path, + y.referrer_path as referrer_path, + y.event_name, + y.created_at as created_at + from level${i} x + join level0 y + on x.session_id = y.session_id + where y.created_at between x.created_at and x.created_at + interval ${window} minute + and y.${column} ${operator} {param${i}:String} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `; + pv.params[`param${i}`] = paramValue; + + return pv; + }, + { + levelOneQuery: '', + levelQuery: '', + sumQuery: '', + stepFilterQuery: '', + params: {}, + }, + ); + } + + return rawQuery( + ` + WITH level0 AS ( + select distinct session_id, url_path, referrer_path, event_name, created_at + from website_event + ${cohortQuery} + where (${stepFilterQuery}) + and website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ), + ${levelOneQuery} + ${levelQuery} + select * + from ( + ${sumQuery} + ) ORDER BY level; + `, + { + ...params, + ...queryParams, + }, + ).then(formatResults(steps)); +} + +const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => { + return steps.map((step: { type: string; value: string }, i: number) => { + const visitors = Number(results[i]?.count) || 0; + const previous = Number(results[i - 1]?.count) || 0; + const dropped = previous > 0 ? previous - visitors : 0; + const dropoff = 1 - visitors / previous; + const remaining = visitors / Number(results[0].count); + + return { + ...step, + visitors, + previous, + dropped, + dropoff, + remaining, + }; + }); +}; diff --git a/src/queries/sql/reports/getGoal.ts b/src/queries/sql/reports/getGoal.ts new file mode 100644 index 0000000..7e790ff --- /dev/null +++ b/src/queries/sql/reports/getGoal.ts @@ -0,0 +1,105 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface GoalParameters { + startDate: Date; + endDate: Date; + type: string; + value: string; + operator?: string; + property?: string; +} + +export async function getGoal( + ...args: [websiteId: string, params: GoalParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: GoalParameters, + filters: QueryFilters, +) { + const { startDate, endDate, type, value } = parameters; + const { rawQuery, parseFilters } = prisma; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + value, + startDate, + endDate, + eventType, + }); + + return rawQuery( + ` + select count(distinct website_event.session_id) as num, + ( + select count(distinct website_event.session_id) + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + ${dateQuery} + ${filterQuery} + ) as total + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and ${column} = {{value}} + ${dateQuery} + ${filterQuery} + `, + queryParams, + ).then(results => results?.[0]); +} + +async function clickhouseQuery( + websiteId: string, + parameters: GoalParameters, + filters: QueryFilters, +) { + const { startDate, endDate, type, value } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + value, + startDate, + endDate, + eventType, + }); + + return rawQuery( + ` + select count(distinct session_id) as num, + ( + select count(distinct session_id) + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${dateQuery} + ${filterQuery} + ) as total + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and ${column} = {value:String} + ${dateQuery} + ${filterQuery} + `, + queryParams, + ).then(results => results?.[0]); +} diff --git a/src/queries/sql/reports/getJourney.ts b/src/queries/sql/reports/getJourney.ts new file mode 100644 index 0000000..283e0fa --- /dev/null +++ b/src/queries/sql/reports/getJourney.ts @@ -0,0 +1,275 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface JourneyParameters { + startDate: Date; + endDate: Date; + steps: number; + startStep?: string; + endStep?: string; +} + +export interface JourneyResult { + e1: string; + e2: string; + e3: string; + e4: string; + e5: string; + e6: string; + e7: string; + count: number; +} + +export async function getJourney( + ...args: [websiteId: string, parameters: JourneyParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: JourneyParameters, + filters: QueryFilters, +): Promise<JourneyResult[]> { + const { startDate, endDate, steps, startStep, endStep } = parameters; + const { rawQuery, parseFilters } = prisma; + const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery( + steps, + startStep, + endStep, + ); + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + + function getJourneyQuery( + steps: number, + startStep?: string, + endStep?: string, + ): { + sequenceQuery: string; + startStepQuery: string; + endStepQuery: string; + params: Record<string, string>; + } { + const params = {}; + let sequenceQuery = ''; + let startStepQuery = ''; + let endStepQuery = ''; + + // create sequence query + let selectQuery = ''; + let maxQuery = ''; + let groupByQuery = ''; + + for (let i = 1; i <= steps; i++) { + const endQuery = i < steps ? ',' : ''; + selectQuery += `s.e${i},`; + maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`; + groupByQuery += `s.e${i}${endQuery} `; + } + + sequenceQuery = `\nsequences as ( + select ${selectQuery} + count(*) count + FROM ( + select visit_id, + ${maxQuery} + FROM events + group by visit_id) s + group by ${groupByQuery}) + `; + + // create start Step params query + if (startStep) { + startStepQuery = `and e1 = {{startStep}}`; + params.startStep = startStep; + } + + // create end Step params query + if (endStep) { + for (let i = 1; i < steps; i++) { + const startQuery = i === 1 ? 'and (' : '\nor '; + endStepQuery += `${startQuery}(e${i} = {{endStep}} and e${i + 1} is null) `; + } + endStepQuery += `\nor (e${steps} = {{endStep}}))`; + + params.endStep = endStep; + } + + return { + sequenceQuery, + startStepQuery, + endStepQuery, + params, + }; + } + + return rawQuery( + ` + WITH events AS ( + select distinct + website_event.visit_id, + website_event.referrer_path, + coalesce(nullIf(website_event.event_name, ''), website_event.url_path) event, + row_number() OVER (PARTITION BY visit_id ORDER BY website_event.created_at) AS event_number + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery}), + ${sequenceQuery} + select * + from sequences + where 1 = 1 + ${startStepQuery} + ${endStepQuery} + order by count desc + limit 100 + `, + { + ...params, + ...queryParams, + }, + ).then(parseResult); +} + +async function clickhouseQuery( + websiteId: string, + parameters: JourneyParameters, + filters: QueryFilters, +): Promise<JourneyResult[]> { + const { startDate, endDate, steps, startStep, endStep } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery( + steps, + startStep, + endStep, + ); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + + function getJourneyQuery( + steps: number, + startStep?: string, + endStep?: string, + ): { + sequenceQuery: string; + startStepQuery: string; + endStepQuery: string; + params: Record<string, string>; + } { + const params = {}; + let sequenceQuery = ''; + let startStepQuery = ''; + let endStepQuery = ''; + + // create sequence query + let selectQuery = ''; + let maxQuery = ''; + let groupByQuery = ''; + + for (let i = 1; i <= steps; i++) { + const endQuery = i < steps ? ',' : ''; + selectQuery += `s.e${i},`; + maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`; + groupByQuery += `s.e${i}${endQuery} `; + } + + sequenceQuery = `\nsequences as ( + select ${selectQuery} + count(*) count + FROM ( + select visit_id, + ${maxQuery} + FROM events + group by visit_id) s + group by ${groupByQuery}) + `; + + // create start Step params query + if (startStep) { + startStepQuery = `and e1 = {startStep:String}`; + params.startStep = startStep; + } + + // create end Step params query + if (endStep) { + for (let i = 1; i < steps; i++) { + const startQuery = i === 1 ? 'and (' : '\nor '; + endStepQuery += `${startQuery}(e${i} = {endStep:String} and e${i + 1} is null) `; + } + endStepQuery += `\nor (e${steps} = {endStep:String}))`; + + params.endStep = endStep; + } + + return { + sequenceQuery, + startStepQuery, + endStepQuery, + params, + }; + } + + return rawQuery( + ` + WITH events AS ( + select distinct + visit_id, + coalesce(nullIf(event_name, ''), url_path) "event", + row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${filterQuery} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}), + ${sequenceQuery} + select * + from sequences + where 1 = 1 + ${startStepQuery} + ${endStepQuery} + order by count desc + limit 100 + `, + { + ...params, + ...queryParams, + }, + ).then(parseResult); +} + +function combineSequentialDuplicates(array: any) { + if (array.length === 0) return array; + + const result = [array[0]]; + + for (let i = 1; i < array.length; i++) { + if (array[i] !== array[i - 1]) { + result.push(array[i]); + } + } + + return result; +} + +function parseResult(data: any) { + return data.map(({ e1, e2, e3, e4, e5, e6, e7, count }) => ({ + items: combineSequentialDuplicates([e1, e2, e3, e4, e5, e6, e7]), + count: +Number(count), + })); +} diff --git a/src/queries/sql/reports/getRetention.ts b/src/queries/sql/reports/getRetention.ts new file mode 100644 index 0000000..87b55e0 --- /dev/null +++ b/src/queries/sql/reports/getRetention.ts @@ -0,0 +1,173 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface RetentionParameters { + startDate: Date; + endDate: Date; + timezone?: string; +} + +export interface RetentionResult { + date: string; + day: number; + visitors: number; + returnVisitors: number; + percentage: number; +} + +export async function getRetention( + ...args: [websiteId: string, parameters: RetentionParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: RetentionParameters, + filters: QueryFilters, +): Promise<RetentionResult[]> { + const { startDate, endDate, timezone } = parameters; + const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery, parseFilters } = prisma; + const unit = 'day'; + + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + timezone, + }); + + return rawQuery( + ` + WITH cohort_items AS ( + select + min(${getDateSQL('website_event.created_at', unit, timezone)}) as cohort_date, + website_event.session_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by website_event.session_id + ), + user_activities AS ( + select distinct + website_event.session_id, + ${getDayDiffQuery(getDateSQL('created_at', unit, timezone), 'cohort_items.cohort_date')} as day_number + from website_event + join cohort_items + on website_event.session_id = cohort_items.session_id + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + + ), + cohort_size as ( + select cohort_date, + count(*) as visitors + from cohort_items + group by 1 + order by 1 + ), + cohort_date as ( + select + c.cohort_date, + a.day_number, + count(*) as visitors + from user_activities a + join cohort_items c + on a.session_id = c.session_id + group by 1, 2 + ) + select + c.cohort_date as date, + c.day_number as day, + s.visitors, + c.visitors as "returnVisitors", + ${getCastColumnQuery('c.visitors', 'float')} * 100 / s.visitors as percentage + from cohort_date c + join cohort_size s + on c.cohort_date = s.cohort_date + where c.day_number <= 31 + order by 1, 2`, + queryParams, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: RetentionParameters, + filters: QueryFilters, +): Promise<RetentionResult[]> { + const { startDate, endDate, timezone } = parameters; + const { getDateSQL, rawQuery, parseFilters } = clickhouse; + const unit = 'day'; + + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + timezone, + }); + + return rawQuery( + ` + WITH cohort_items AS ( + select + min(${getDateSQL('created_at', unit, timezone)}) as cohort_date, + session_id + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by session_id + ), + user_activities AS ( + select distinct + website_event.session_id, + toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number + from website_event + join cohort_items + on website_event.session_id = cohort_items.session_id + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ), + cohort_size as ( + select cohort_date, + count(*) as visitors + from cohort_items + group by 1 + order by 1 + ), + cohort_date as ( + select + c.cohort_date, + a.day_number, + count(*) as visitors + from user_activities a + join cohort_items c + on a.session_id = c.session_id + group by 1, 2 + ) + select + c.cohort_date as date, + c.day_number as day, + s.visitors as visitors, + c.visitors returnVisitors, + c.visitors * 100 / s.visitors as percentage + from cohort_date c + join cohort_size s + on c.cohort_date = s.cohort_date + where c.day_number <= 31 + order by 1, 2`, + queryParams, + ); +} diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts new file mode 100644 index 0000000..fa25078 --- /dev/null +++ b/src/queries/sql/reports/getRevenue.ts @@ -0,0 +1,217 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface RevenuParameters { + startDate: Date; + endDate: Date; + unit: string; + timezone: string; + currency: string; +} + +export interface RevenueResult { + chart: { x: string; t: string; y: number }[]; + country: { name: string; value: number }[]; + total: { sum: number; count: number; average: number; unique_count: number }; +} + +export async function getRevenue( + ...args: [websiteId: string, parameters: RevenuParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: RevenuParameters, + filters: QueryFilters, +): Promise<RevenueResult> { + const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; + const { getDateSQL, rawQuery, parseFilters } = prisma; + const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = filterQuery + ? `join website_event + on website_event.website_id = revenue.website_id + and website_event.session_id = revenue.session_id + and website_event.event_id = revenue.event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}}` + : ''; + + const chart = await rawQuery( + ` + select + revenue.event_name x, + ${getDateSQL('revenue.created_at', unit, timezone)} t, + sum(revenue.revenue) y + from revenue + ${joinQuery} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.currency = upper({{currency}}) + ${filterQuery} + group by x, t + order by t + `, + queryParams, + ); + + const country = await rawQuery( + ` + select + session.country as name, + sum(revenue) value + from revenue + ${joinQuery} + join session + on session.website_id = revenue.website_id + and session.session_id = revenue.session_id + ${cohortQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.currency = upper({{currency}}) + ${filterQuery} + group by session.country + `, + queryParams, + ); + + const total = await rawQuery( + ` + select + sum(revenue.revenue) as sum, + count(distinct revenue.event_id) as count, + count(distinct revenue.session_id) as unique_count + from revenue + ${joinQuery} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.currency = upper({{currency}}) + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0; + + return { chart, country, total }; +} + +async function clickhouseQuery( + websiteId: string, + parameters: RevenuParameters, + filters: QueryFilters, +): Promise<RevenueResult> { + const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; + const { getDateSQL, rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = filterQuery + ? `join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}` + : ''; + + const chart = await rawQuery< + { + x: string; + t: string; + y: number; + }[] + >( + ` + select + website_revenue.event_name x, + ${getDateSQL('website_revenue.created_at', unit, timezone)} t, + sum(website_revenue.revenue) y + from website_revenue + ${joinQuery} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = upper({currency:String}) + ${filterQuery} + group by x, t + order by t + `, + queryParams, + ); + + const country = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + select + website_event.country as name, + sum(website_revenue.revenue) as value + from website_revenue + join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = upper({currency:String}) + ${filterQuery} + group by website_event.country + order by value desc + `, + queryParams, + ); + + const total = await rawQuery<{ + sum: number; + count: number; + unique_count: number; + }>( + ` + select + sum(website_revenue.revenue) as sum, + uniqExact(website_revenue.event_id) as count, + uniqExact(website_revenue.session_id) as unique_count + from website_revenue + ${joinQuery} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = upper({currency:String}) + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + total.average = total.count > 0 ? total.sum / total.count : 0; + + return { chart, country, total }; +} diff --git a/src/queries/sql/reports/getUTM.ts b/src/queries/sql/reports/getUTM.ts new file mode 100644 index 0000000..4d43eb4 --- /dev/null +++ b/src/queries/sql/reports/getUTM.ts @@ -0,0 +1,84 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface UTMParameters { + column: string; + startDate: Date; + endDate: Date; +} + +export async function getUTM( + ...args: [websiteId: string, parameters: UTMParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: UTMParameters, + filters: QueryFilters, +) { + const { column, startDate, endDate } = parameters; + const { parseFilters, rawQuery } = prisma; + + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }); + + return rawQuery( + ` + select website_event.${column} utm, count(*) as views + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and coalesce(website_event.${column}, '') != '' + ${filterQuery} + group by 1 + order by 2 desc + `, + queryParams, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: UTMParameters, + filters: QueryFilters, +) { + const { column, startDate, endDate } = parameters; + const { parseFilters, rawQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }); + + return rawQuery( + ` + select ${column} utm, count(*) as views + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and ${column} != '' + ${filterQuery} + group by 1 + order by 2 desc + `, + queryParams, + ); +} |