diff --git a/src/app/(main)/reports/attribution/AttributionParameters.tsx b/src/app/(main)/reports/attribution/AttributionParameters.tsx index f9f2915d..7817b85e 100644 --- a/src/app/(main)/reports/attribution/AttributionParameters.tsx +++ b/src/app/(main)/reports/attribution/AttributionParameters.tsx @@ -1,69 +1,91 @@ import { useMessages } from '@/components/hooks'; +import Icons from '@/components/icons'; import { useContext, useState } from 'react'; -import { Dropdown, Form, FormButtons, FormInput, FormRow, Item, SubmitButton } from 'react-basics'; +import { + Button, + Dropdown, + Form, + FormButtons, + FormInput, + FormRow, + Icon, + Item, + Popup, + PopupTrigger, + SubmitButton, +} from 'react-basics'; import BaseParameters from '../[reportId]/BaseParameters'; +import ParameterList from '../[reportId]/ParameterList'; +import PopupForm from '../[reportId]/PopupForm'; import { ReportContext } from '../[reportId]/Report'; +import FunnelStepAddForm from '../funnel/FunnelStepAddForm'; +import styles from './AttributionParameters.module.css'; +import AttributionStepAddForm from './AttributionStepAddForm'; export function AttributionParameters() { - const [model, setModel] = useState('firstClick'); - const { report, runReport, isRunning } = useContext(ReportContext); + const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); const { id, parameters } = report || {}; - const { websiteId, dateRange } = parameters || {}; - const queryEnabled = websiteId && dateRange; + const { websiteId, dateRange, steps } = parameters || {}; + const queryEnabled = websiteId && dateRange && steps.length > 0; + const [model, setModel] = useState(''); const handleSubmit = (data: any, e: any) => { e.stopPropagation(); e.preventDefault(); - runReport(data); }; - // const handleAddStep = (step: { type: string; value: string }) => { - // updateReport({ parameters: { steps: parameters.steps.concat(step) } }); - // }; + const handleAddStep = (step: { type: string; value: string }) => { + updateReport({ parameters: { steps: parameters.steps.concat(step) } }); + }; - // const handleUpdateStep = ( - // close: () => void, - // index: number, - // step: { type: string; value: string }, - // ) => { - // const steps = [...parameters.steps]; - // steps[index] = step; - // updateReport({ parameters: { steps } }); - // close(); - // }; + const handleUpdateStep = ( + close: () => void, + index: number, + step: { type: string; value: string }, + ) => { + const steps = [...parameters.steps]; + steps[index] = step; + updateReport({ parameters: { steps } }); + close(); + }; - // const handleRemoveStep = (index: number) => { - // const steps = [...parameters.steps]; - // delete steps[index]; - // updateReport({ parameters: { steps: steps.filter(n => n) } }); - // }; + const handleRemoveStep = (index: number) => { + const steps = [...parameters.steps]; + delete steps[index]; + updateReport({ parameters: { steps: steps.filter(n => n) } }); + }; - // const AddStepButton = () => { - // return ( - // - // - // - // - // - // - // - // - // ); - // }; + const AddStepButton = () => { + return ( + 0}> + + + + + + + + ); + }; - const attributionModel = [ + const items = [ { label: 'First-Click', value: 'firstClick' }, { label: 'Last-Click', value: 'lastClick' }, ]; const renderModelValue = (value: any) => { - return attributionModel.find(item => item.value === value)?.label; + return items.find(item => item.value === value)?.label; + }; + + const onModelChange = (value: any) => { + setModel(value); + updateReport({ parameters: { model } }); }; return ( @@ -72,10 +94,10 @@ export function AttributionParameters() { setModel(value)} - items={attributionModel} + onChange={onModelChange} > {({ value, label }) => { return {label}; @@ -83,7 +105,7 @@ export function AttributionParameters() { - {/* }> + }> {steps.map((step: { type: string; value: string }, index: number) => { return ( @@ -100,7 +122,7 @@ export function AttributionParameters() { {(close: () => void) => ( - - */} + {formatMessage(labels.runQuery)} diff --git a/src/app/(main)/reports/attribution/AttributionReport.tsx b/src/app/(main)/reports/attribution/AttributionReport.tsx index 90b0b536..c33a4195 100644 --- a/src/app/(main)/reports/attribution/AttributionReport.tsx +++ b/src/app/(main)/reports/attribution/AttributionReport.tsx @@ -9,7 +9,7 @@ import AttributionView from './AttributionView'; const defaultParameters = { type: REPORT_TYPES.attribution, - parameters: {}, + parameters: { model: 'firstClick', steps: [] }, }; export default function AttributionReport({ reportId }: { reportId?: string }) { diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts new file mode 100644 index 00000000..6033c633 --- /dev/null +++ b/src/app/api/reports/attribution/route.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; +import { canViewWebsite } from '@/lib/auth'; +import { unauthorized, json } from '@/lib/response'; +import { parseRequest } from '@/lib/request'; +import { getFunnel } from '@/queries'; +import { reportParms } from '@/lib/schema'; + +export async function POST(request: Request) { + const schema = z.object({ + ...reportParms, + window: z.coerce.number().positive(), + steps: z + .array( + z.object({ + type: z.string(), + value: z.string(), + }), + ) + .min(2), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + steps, + window, + dateRange: { startDate, endDate }, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getFunnel(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + steps, + windowMinutes: +window, + }); + + return json(data); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index 653b0065..51ecc615 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -263,6 +263,7 @@ export const labels = defineMessages({ id: 'label.utm-description', defaultMessage: 'Track your campaigns through UTM parameters.', }, + conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion Step' }, steps: { id: 'label.steps', defaultMessage: 'Steps' }, startStep: { id: 'label.start-step', defaultMessage: 'Start Step' }, endStep: { id: 'label.end-step', defaultMessage: 'End Step' }, diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts new file mode 100644 index 00000000..70b51a9d --- /dev/null +++ b/src/queries/sql/reports/getAttribution.ts @@ -0,0 +1,250 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +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, + }; + }); +}; + +export async function getFunnel( + ...args: [ + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + steps: { type: string; value: string }[]; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + steps: { type: string; value: string }[]; + }, +): Promise< + { + value: string; + visitors: number; + dropoff: number; + }[] +> { + const { windowMinutes, startDate, endDate, steps } = criteria; + const { rawQuery, getAddIntervalQuery } = prisma; + const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, windowMinutes); + + function getFunnelQuery( + steps: { type: string; value: string }[], + windowMinutes: 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 === 'url'; + 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 session_id, created_at + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and ${column} ${operator} {{${i}}} + )`; + } 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 `, + `${windowMinutes} 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; + `, + { + websiteId, + startDate, + endDate, + ...params, + }, + ).then(formatResults(steps)); +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + steps: { type: string; value: string }[]; + }, +): Promise< + { + value: string; + visitors: number; + dropoff: number; + }[] +> { + const { windowMinutes, startDate, endDate, steps } = criteria; + const { rawQuery } = clickhouse; + const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery( + steps, + windowMinutes, + ); + + function getFunnelQuery( + steps: { type: string; value: string }[], + windowMinutes: number, + ): { + levelOneQuery: string; + levelQuery: string; + sumQuery: string; + stepFilterQuery: string; + params: { [key: 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 === 'url'; + 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 ${windowMinutes} 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 umami.website_event + where (${stepFilterQuery}) + and website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ), + ${levelOneQuery} + ${levelQuery} + select * + from ( + ${sumQuery} + ) ORDER BY level; + `, + { + websiteId, + startDate, + endDate, + ...params, + }, + ).then(formatResults(steps)); +}