diff --git a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx index d8110ca90..481a215b7 100644 --- a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx +++ b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx @@ -12,7 +12,7 @@ import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/component import { ROLES } from '@/lib/constants'; export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) { - const { t, labels, messages, getMessage } = useMessages(); + const { t, labels, messages, getErrorMessage } = useMessages(); const user = useUser(); const { user: login } = useLoginQuery(); @@ -30,7 +30,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () = }; return ( -
+ diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx index 3a7b2058b..abf06aead 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx @@ -1,19 +1,16 @@ -import { Column, Grid, Row, Text } from '@umami/react-zen'; -import classNames from 'classnames'; -import { colord } from 'colord'; -import { useCallback, useMemo, useState } from 'react'; -import { BarChart } from '@/components/charts/BarChart'; +import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { GridRow } from '@/components/common/GridRow'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; -import { TypeIcon } from '@/components/common/TypeIcon'; -import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks'; +import { useDateRange, useMessages, useResultQuery } from '@/components/hooks'; import { CurrencySelect } from '@/components/input/CurrencySelect'; import { ListTable } from '@/components/metrics/ListTable'; import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricLabel } from '@/components/metrics/MetricLabel'; import { MetricsBar } from '@/components/metrics/MetricsBar'; -import { renderDateLabels } from '@/lib/charts'; -import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants'; -import { generateTimeSeries } from '@/lib/date'; +import { RevenueChart } from '@/components/metrics/RevenueChart'; +import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants'; import { formatLongCurrency, formatLongNumber } from '@/lib/format'; import { getItem, setItem } from '@/lib/storage'; @@ -35,83 +32,49 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { }; const { t, labels } = useMessages(); - const { locale, dateLocale } = useLocale(); - const { countryNames } = useCountryNames(locale); + const { compare, isAllTime } = useDateRange(); const { data, error, isLoading } = useResultQuery('revenue', { websiteId, startDate, endDate, currency, + compare, }); - const renderCountryName = useCallback( - ({ label: code }) => ( - - - {countryNames[code] || t(labels.unknown)} - - ), - [countryNames, locale], - ); - - const chartData: any = useMemo(() => { - if (!data) return []; - - const map = (data.chart as any[]).reduce((obj, { x, t, y }) => { - if (!obj[x]) { - obj[x] = []; - } - - obj[x].push({ x: t, y }); - - return obj; - }, {}); - - return { - datasets: Object.keys(map).map((key, index) => { - const color = colord(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]); - const metrics = useMemo(() => { if (!data) return []; - const { sum, count, unique_count } = data.total; + const { sum, count, average, unique_count, comparison } = data.total; return [ { value: sum, label: t(labels.total), - formatValue: n => formatLongCurrency(n, currency), + change: comparison ? sum - comparison.sum : 0, + formatValue: (n: number) => formatLongCurrency(n, currency), }, { - value: count ? sum / count : 0, + value: average, label: t(labels.average), - formatValue: n => formatLongCurrency(n, currency), + change: comparison ? average - comparison.average : 0, + formatValue: (n: number) => formatLongCurrency(n, currency), }, { value: count, label: t(labels.transactions), + change: comparison ? count - comparison.count : 0, formatValue: formatLongNumber, }, { value: unique_count, label: t(labels.uniqueCustomers), + change: comparison ? unique_count - comparison.unique_count : 0, formatValue: formatLongNumber, }, ] as any; - }, [data, locale]); + }, [data]); - const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + const renderLabel = (type: string) => (data: any) => ; return ( @@ -122,37 +85,119 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { {data && ( - {metrics?.map(({ label, value, formatValue }) => { + {metrics?.map(({ label, value, change, formatValue }) => { return ( - + ); })} - - - - ({ - label: name, - count: Number(value), - percent: (value / data?.total.sum) * 100, - }))} - currency={currency} - renderLabel={renderCountryName} /> + + + + {t(labels.sources)} + + + {t(labels.referrers)} + {t(labels.channels)} + + + ({ + label: name, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }), + )} + currency={currency} + renderLabel={renderLabel('referrer')} + /> + + + ({ + label: name, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }), + )} + currency={currency} + renderLabel={renderLabel('channel')} + /> + + + + + {t(labels.location)} + + + {t(labels.countries)} + {t(labels.regions)} + + + ({ + label: name, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }), + )} + currency={currency} + renderLabel={renderLabel('country')} + /> + + + ({ + label: name, + country, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }), + )} + currency={currency} + renderLabel={renderLabel('region')} + /> + + + + + )} diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts index 6a556612b..2f87f80e0 100644 --- a/src/app/api/reports/revenue/route.ts +++ b/src/app/api/reports/revenue/route.ts @@ -1,8 +1,11 @@ +import { getCompareDate } from '@/lib/date'; import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { reportResultSchema } from '@/lib/schema'; import { canViewWebsite } from '@/permissions'; import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue'; +import { getRevenueMetrics } from '@/queries/sql/reports/getRevenueMetrics'; +import { getRevenueStats } from '@/queries/sql/reports/getRevenueStats'; export async function POST(request: Request) { const { auth, body, error } = await parseRequest(request, reportResultSchema); @@ -20,7 +23,19 @@ export async function POST(request: Request) { const parameters = await setWebsiteDate(websiteId, body.parameters); const filters = await getQueryFilters(body.filters, websiteId); - const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); + const [{ chart }, total, metrics] = await Promise.all([ + getRevenue(websiteId, parameters as RevenuParameters, filters), + getRevenueStats(websiteId, parameters as RevenuParameters, filters), + getRevenueMetrics(websiteId, parameters as RevenuParameters, filters), + ]); - return json(data); + const { compare = 'prev' } = parameters as RevenuParameters; + const { startDate, endDate } = getCompareDate(compare, parameters.startDate, parameters.endDate); + const comparison = await getRevenueStats( + websiteId, + { ...(parameters as RevenuParameters), startDate, endDate }, + filters, + ); + + return json({ chart, total: { ...total, comparison }, ...metrics }); } diff --git a/src/components/metrics/RevenueChart.tsx b/src/components/metrics/RevenueChart.tsx new file mode 100644 index 000000000..bed54706b --- /dev/null +++ b/src/components/metrics/RevenueChart.tsx @@ -0,0 +1,60 @@ +import { colord } from 'colord'; +import { useCallback, useMemo } from 'react'; +import { BarChart } from '@/components/charts/BarChart'; +import { useLocale } from '@/components/hooks'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { generateTimeSeries } from '@/lib/date'; + +export interface RevenueChartProps { + data: { x: string; t: string; y: number; count: number }[]; + unit: string; + minDate: Date; + maxDate: Date; + currency: string; +} + +export function RevenueChart({ data, unit, minDate, maxDate, currency }: RevenueChartProps) { + const { locale, dateLocale } = useLocale(); + + const chartData: any = useMemo(() => { + if (!data?.length) return { datasets: [] }; + + const map = data.reduce( + (obj, { x, t, y }) => { + if (!obj[x]) obj[x] = []; + obj[x].push({ x: t, y }); + return obj; + }, + {} as Record, + ); + + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: generateTimeSeries(map[key], minDate, maxDate, unit, dateLocale), + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; + }, [data, minDate, maxDate, unit, dateLocale]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + + ); +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 8693d30bc..41cfc70f9 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -229,6 +229,7 @@ export const revenueReportSchema = z.object({ unit: unitParam.optional(), timezone: timezoneParam.optional(), currency: z.string(), + compare: z.enum(['prev', 'yoy']).optional(), }), }); diff --git a/src/queries/sql/getChannelExpandedMetrics.ts b/src/queries/sql/getChannelExpandedMetrics.ts index 2a055ff36..1c6197064 100644 --- a/src/queries/sql/getChannelExpandedMetrics.ts +++ b/src/queries/sql/getChannelExpandedMetrics.ts @@ -91,6 +91,7 @@ async function relationalQuery( when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') + when referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral' else '' end as "name", session_id, visit_id, @@ -136,31 +137,33 @@ async function clickhouseQuery( sum(if(t.c = 1, 1, 0)) as "bounces", sum(max_time-min_time) as "totaltime" from ( - select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, - case + select + case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, + case when referrer_domain = '' and url_query = '' then 'direct' - when multiSearchAny(url_query, [${toClickHouseStringArray( + when multiSearchAny(lower(url_query), [${toClickHouseStringArray( PAID_AD_PARAMS, )}]) != 0 then 'paidAds' - when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' - when position(utm_medium, 'affiliate') > 0 then 'affiliate' - when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral' + when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate' + when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms' + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( SEARCH_DOMAINS, - )}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( SOCIAL_DOMAINS, )}]) != 0 then concat(prefix, 'Social') - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( EMAIL_DOMAINS, - )}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email' + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( SHOPPING_DOMAINS, - )}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( VIDEO_DOMAINS, - )}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') - else '' end AS name, + )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video') + when referrer_domain != hostname and referrer_domain != '' then 'referral' + else '' end AS name, session_id, visit_id, count(*) c, diff --git a/src/queries/sql/getChannelMetrics.ts b/src/queries/sql/getChannelMetrics.ts index 9d14af058..524d42ac4 100644 --- a/src/queries/sql/getChannelMetrics.ts +++ b/src/queries/sql/getChannelMetrics.ts @@ -61,6 +61,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') + wwhen referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral' else '' end AS x, count(distinct session_id) y from prefix @@ -90,31 +91,33 @@ async function clickhouseQuery( const sql = ` WITH channels as ( - select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, - case + select + case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, + case when referrer_domain = '' and url_query = '' then 'direct' - when multiSearchAny(url_query, [${toClickHouseStringArray( + when multiSearchAny(lower(url_query), [${toClickHouseStringArray( PAID_AD_PARAMS, )}]) != 0 then 'paidAds' - when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' - when position(utm_medium, 'affiliate') > 0 then 'affiliate' - when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral' + when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate' + when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms' + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( SEARCH_DOMAINS, - )}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( SOCIAL_DOMAINS, )}]) != 0 then concat(prefix, 'Social') - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( EMAIL_DOMAINS, - )}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email' + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( SHOPPING_DOMAINS, - )}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') - when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( VIDEO_DOMAINS, - )}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') - else '' end AS x, + )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video') + when referrer_domain != hostname and referrer_domain != '' then 'referral' + else '' end AS x, count(distinct session_id) y from website_event ${cohortQuery} diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts index 30d7d7f1f..183861e12 100644 --- a/src/queries/sql/reports/getRevenue.ts +++ b/src/queries/sql/reports/getRevenue.ts @@ -9,12 +9,7 @@ export interface RevenuParameters { 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 }; + compare?: string; } export async function getRevenue( @@ -30,7 +25,7 @@ async function relationalQuery( websiteId: string, parameters: RevenuParameters, filters: QueryFilters, -): Promise { +) { const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; const { getDateSQL, rawQuery, parseFilters } = prisma; const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({ @@ -58,7 +53,8 @@ async function relationalQuery( select revenue.event_name x, ${getDateSQL('revenue.created_at', unit, timezone)} t, - sum(revenue.revenue) y + sum(revenue.revenue) y, + count(revenue.event_id) count from revenue ${joinQuery} ${cohortQuery} @@ -73,54 +69,14 @@ async function relationalQuery( queryParams, ); - const country = await rawQuery( - ` - select - session.country as "name", - sum(revenue) as "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 upper(revenue.currency) = {{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 upper(revenue.currency) = {{currency}} - ${filterQuery} - `, - queryParams, - ).then(result => result?.[0]); - - total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0; - - return { chart, country, total }; + return { chart }; } async function clickhouseQuery( websiteId: string, parameters: RevenuParameters, filters: QueryFilters, -): Promise { +) { const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; const { getDateSQL, rawQuery, parseFilters } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ @@ -133,7 +89,7 @@ async function clickhouseQuery( const joinQuery = filterQuery ? `any left join ( - select * + select * from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} @@ -143,18 +99,13 @@ async function clickhouseQuery( and website_event.event_id = website_revenue.event_id` : ''; - const chart = await rawQuery< - { - x: string; - t: string; - y: number; - }[] - >( + const chart = await rawQuery<{ x: string; t: string; y: number; count: number }[]>( ` select website_revenue.event_name x, ${getDateSQL('website_revenue.created_at', unit, timezone)} t, - sum(website_revenue.revenue) y + sum(website_revenue.revenue) y, + count(website_revenue.event_id) count from website_revenue ${joinQuery} ${cohortQuery} @@ -168,59 +119,5 @@ async function clickhouseQuery( queryParams, ); - const country = await rawQuery< - { - name: string; - value: number; - }[] - >( - ` - select - website_event.country as "name", - sum(website_revenue.revenue) as "value" - from website_revenue - any left join ( - select * - from website_event - where website_id = {websiteId:UUID} - and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = 2) 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 - ${cohortQuery} - where website_revenue.website_id = {websiteId:UUID} - and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and upper(website_revenue.currency) = {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 upper(website_revenue.currency) = {currency:String} - ${filterQuery} - `, - queryParams, - ).then(result => result?.[0]); - - total.average = total.count > 0 ? total.sum / total.count : 0; - - return { chart, country, total }; + return { chart }; } diff --git a/src/queries/sql/reports/getRevenueMetrics.ts b/src/queries/sql/reports/getRevenueMetrics.ts new file mode 100644 index 000000000..6553d2e09 --- /dev/null +++ b/src/queries/sql/reports/getRevenueMetrics.ts @@ -0,0 +1,451 @@ +import clickhouse from '@/lib/clickhouse'; +import { + EMAIL_DOMAINS, + PAID_AD_PARAMS, + SEARCH_DOMAINS, + SHOPPING_DOMAINS, + SOCIAL_DOMAINS, + VIDEO_DOMAINS, +} from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; +import type { RevenuParameters } from './getRevenue'; + +export interface RevenueMetricsResult { + country: { name: string; value: number }[]; + region: { name: string; value: number; country: string }[]; + referrer: { name: string; value: number }[]; + channel: { name: string; value: number }[]; +} + +export async function getRevenueMetrics( + ...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 { + const { startDate, endDate, currency } = parameters; + const { rawQuery, parseFilters } = prisma; + const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = + filterQuery || cohortQuery + ? `join (select * + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and event_type = 2) 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` + : ''; + + const country = await rawQuery( + ` + select + session.country as "name", + sum(revenue) as "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 upper(revenue.currency) = {{currency}} + ${filterQuery} + group by session.country + order by value desc + `, + queryParams, + ); + + const region = await rawQuery( + ` + select + session.country, + session.region as "name", + sum(revenue.revenue) as "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 upper(revenue.currency) = {{currency}} + ${filterQuery} + group by session.country, session.region + order by value desc + `, + queryParams, + ); + + const referrer = await rawQuery( + ` + WITH events AS ( + select + revenue.website_id, + revenue.session_id, + sum(revenue.revenue) as "value" + from revenue + ${joinQuery} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and upper(revenue.currency) = {{currency}} + ${filterQuery} + group by revenue.website_id, revenue.session_id), + + revenue_data AS ( + select + e.website_id, + e.session_id, + e.value, + we.min_date as created_at + from events e + join ( + select session_id, min(created_at) as min_date + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + group by session_id + ) we on we.session_id = e.session_id) + + select + we.referrer_domain as "name", + sum(revenue_data.value) as "value" + from revenue_data + join ( + select website_id, session_id, referrer_domain, created_at + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) we + on we.website_id = revenue_data.website_id + and we.session_id = revenue_data.session_id + and we.created_at = revenue_data.created_at + group by we.referrer_domain + order by value desc + `, + queryParams, + ); + + const channel = await rawQuery( + ` + WITH events AS ( + select + revenue.website_id, + revenue.session_id, + sum(revenue.revenue) as "value" + from revenue + ${joinQuery} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and upper(revenue.currency) = {{currency}} + ${filterQuery} + group by revenue.website_id, revenue.session_id), + + revenue_data AS ( + select + e.website_id, + e.session_id, + e.value, + we.min_date as created_at + from events e + join ( + select session_id, min(created_at) as min_date + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + group by session_id + ) we on we.session_id = e.session_id), + + revenue_prefix AS ( + select + case when we.utm_medium ilike '%cp%' OR + we.utm_medium ilike '%ppc%' OR + we.utm_medium ilike '%retargeting%' OR + we.utm_medium ilike '%paid%' then 'paid' else 'organic' end AS prefix, + we.referrer_domain, + we.url_query, + we.utm_medium, + we.utm_source, + we.hostname, + r.value + from revenue_data r + join ( + select website_id, session_id, referrer_domain, url_query, utm_medium, utm_source, hostname, created_at + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) we + on we.website_id = r.website_id + and we.session_id = r.session_id + and we.created_at = r.created_at), + + channels AS ( + select + case + when referrer_domain = '' and url_query = '' then 'direct' + when ${toPostgresLikeClause('url_query', PAID_AD_PARAMS)} then 'paidAds' + when ${toPostgresLikeClause('utm_medium', ['referral', 'app', 'link'])} then 'referral' + when utm_medium ilike '%affiliate%' then 'affiliate' + when utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms' + when ${toPostgresLikeClause('referrer_domain', SEARCH_DOMAINS)} or utm_medium ilike '%organic%' then concat(prefix, 'Search') + when ${toPostgresLikeClause('referrer_domain', SOCIAL_DOMAINS)} then concat(prefix, 'Social') + when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' + when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') + when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') + when referrer_domain != regexp_replace(hostname, '^www.', '') and referrer_domain != '' then 'referral' + else 'Unknown' end AS "name", + value + from revenue_prefix) + + select name, sum(value) as value + from channels + group by name + order by value desc + `, + queryParams, + ); + + return { country, region, referrer, channel }; +} + +async function clickhouseQuery( + websiteId: string, + parameters: RevenuParameters, + filters: QueryFilters, +): Promise { + const { startDate, endDate, currency } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = filterQuery + ? `any left join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2) 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` + : ''; + + const country = await rawQuery<{ name: string; value: number }[]>( + ` + select + website_event.country as "name", + sum(website_revenue.revenue) as "value" + from website_revenue + any left join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2) 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 + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and upper(website_revenue.currency) = {currency:String} + ${filterQuery} + group by website_event.country + order by value desc + `, + queryParams, + ); + + const region = await rawQuery<{ name: string; value: number; country: string }[]>( + ` + select + website_event.country, + website_event.region as "name", + sum(website_revenue.revenue) as "value" + from website_revenue + any left join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2) 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 + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and upper(website_revenue.currency) = {currency:String} + ${filterQuery} + group by 1,2 + order by value desc + `, + queryParams, + ); + + const referrer = await rawQuery<{ name: string; value: number }[]>( + ` + WITH events AS ( + select distinct + website_id, + session_id, + sum(revenue) as "value" + from website_revenue + ${joinQuery} + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and upper(currency) = {currency:String} + ${filterQuery} + group by 1,2), + + revenue AS ( + select + e.website_id, + e.session_id, + e.value, + we.min_date as created_at + from events e + join (select session_id, min(created_at) min_date + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by 1 + ) we + on we.session_id = e.session_id) + + select + website_event.referrer_domain as "name", + sum(revenue.value) as "value" + from revenue + any left join ( + select website_id, session_id, referrer_domain, created_at + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}) website_event + on website_event.website_id = revenue.website_id + and website_event.session_id = revenue.session_id + and website_event.created_at = revenue.created_at + group by 1 + order by value desc + `, + queryParams, + ); + + const channel = await rawQuery<{ name: string; value: number }[]>( + ` + WITH events AS ( + select distinct + website_id, + session_id, + sum(revenue) as "value" + from website_revenue + ${joinQuery} + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and upper(currency) = {currency:String} + ${filterQuery} + group by 1,2), + + revenue AS ( + select + e.website_id, + e.session_id, + e.value, + we.min_date as created_at + from events e + join (select session_id, min(created_at) min_date + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by 1 + ) we + on we.session_id = e.session_id), + + channels AS ( + select + case when multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, + case + when referrer_domain = '' and url_query = '' then 'direct' + when multiSearchAny(lower(url_query), [${toClickHouseStringArray( + PAID_AD_PARAMS, + )}]) != 0 then 'paidAds' + when multiSearchAny(lower(utm_medium), ['referral', 'app','link']) != 0 then 'referral' + when position(lower(utm_medium), 'affiliate') > 0 then 'affiliate' + when position(lower(utm_medium), 'sms') > 0 or position(lower(utm_source), 'sms') > 0 then 'sms' + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( + SEARCH_DOMAINS, + )}]) != 0 or position(lower(utm_medium), 'organic') > 0 then concat(prefix, 'Search') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( + SOCIAL_DOMAINS, + )}]) != 0 then concat(prefix, 'Social') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( + EMAIL_DOMAINS, + )}]) != 0 or position(lower(utm_medium), 'mail') > 0 then 'email' + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( + SHOPPING_DOMAINS, + )}]) != 0 or position(lower(utm_medium), 'shop') > 0 then concat(prefix, 'Shopping') + when multiSearchAny(lower(referrer_domain), [${toClickHouseStringArray( + VIDEO_DOMAINS, + )}]) != 0 or position(lower(utm_medium), 'video') > 0 then concat(prefix, 'Video') + when referrer_domain != hostname and referrer_domain != '' then 'referral' + else 'Unknown' end AS "name", + sum(revenue.value) as "value" + from revenue + any left join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}) website_event + on website_event.website_id = revenue.website_id + and website_event.session_id = revenue.session_id + and website_event.created_at = revenue.created_at + group by 1, 2) + + select name, sum(value) value + from channels + group by 1 + order by value desc; + `, + queryParams, + ); + + return { country, region, referrer, channel }; +} + +function toClickHouseStringArray(arr: string[]): string { + return arr.map(p => `'${p.replace(/'/g, "\\'")}'`).join(', '); +} + +function toPostgresLikeClause(column: string, arr: string[]) { + return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n '); +} diff --git a/src/queries/sql/reports/getRevenueStats.ts b/src/queries/sql/reports/getRevenueStats.ts new file mode 100644 index 000000000..c0eac90fe --- /dev/null +++ b/src/queries/sql/reports/getRevenueStats.ts @@ -0,0 +1,120 @@ +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 { RevenuParameters } from './getRevenue'; + +export interface RevenueStatsResult { + sum: number; + count: number; + average: number; + unique_count: number; +} + +export async function getRevenueStats( + ...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 { + const { startDate, endDate, currency } = parameters; + const { rawQuery, parseFilters } = prisma; + const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = + filterQuery || cohortQuery + ? `join (select * + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and event_type = 2) 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` + : ''; + + 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 upper(revenue.currency) = {{currency}} + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0; + + return total; +} + +async function clickhouseQuery( + websiteId: string, + parameters: RevenuParameters, + filters: QueryFilters, +): Promise { + const { startDate, endDate, currency } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = filterQuery + ? `any left join ( + select * + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = 2) 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` + : ''; + + 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 upper(website_revenue.currency) = {currency:String} + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + total.average = total.count > 0 ? total.sum / total.count : 0; + + return total; +}