diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js index b5532ba0..95d27306 100644 --- a/components/layout/NavBar.js +++ b/components/layout/NavBar.js @@ -20,6 +20,7 @@ export function NavBar() { { label: formatMessage(labels.dashboard), url: '/dashboard' }, { label: formatMessage(labels.reports), url: '/reports' }, { label: formatMessage(labels.realtime), url: '/realtime' }, + { label: formatMessage(labels.reports), url: '/reports/funnel' }, !cloudMode && { label: formatMessage(labels.settings), url: '/settings' }, ].filter(n => n); diff --git a/components/layout/ReportsLayout.js b/components/layout/ReportsLayout.js new file mode 100644 index 00000000..fd63a67e --- /dev/null +++ b/components/layout/ReportsLayout.js @@ -0,0 +1,23 @@ +import { Column, Row } from 'react-basics'; +import styles from './ReportsLayout.module.css'; + +export function SettingsLayout({ children, filter, header }) { + return ( + <> + {header} + + {filter && ( + +

Filters

+ {filter} +
+ )} + + {children} + +
+ + ); +} + +export default SettingsLayout; diff --git a/components/layout/ReportsLayout.module.css b/components/layout/ReportsLayout.module.css new file mode 100644 index 00000000..6922665f --- /dev/null +++ b/components/layout/ReportsLayout.module.css @@ -0,0 +1,23 @@ +.filter { + margin-top: 30px; + min-width: 200px; + max-width: 100vw; + padding: 10px; + background: var(--base50); + border-radius: 5px; + border: 1px solid var(--border-color); +} + +.filter h2 { + padding-bottom: 20px; +} + +.content { + min-height: 50vh; +} + +@media only screen and (max-width: 768px) { + .menu { + display: none; + } +} diff --git a/components/messages.js b/components/messages.js index bdc8770d..a89a800b 100644 --- a/components/messages.js +++ b/components/messages.js @@ -18,6 +18,7 @@ export const labels = defineMessages({ admin: { id: 'label.admin', defaultMessage: 'Administrator' }, confirm: { id: 'label.confirm', defaultMessage: 'Confirm' }, details: { id: 'label.details', defaultMessage: 'Details' }, + website: { id: 'label.website', defaultMessage: 'Website' }, websites: { id: 'label.websites', defaultMessage: 'Websites' }, created: { id: 'label.created', defaultMessage: 'Created' }, edit: { id: 'label.edit', defaultMessage: 'Edit' }, @@ -186,6 +187,10 @@ export const messages = defineMessages({ id: 'message.delete-website-warning', defaultMessage: 'All website data will be deleted.', }, + noResultsFound: { + id: 'messages.no-results-found', + defaultMessage: 'No results were found.', + }, noWebsitesConfigured: { id: 'messages.no-websites-configured', defaultMessage: 'You do not have any websites configured.', diff --git a/components/pages/reports/ReportForm.js b/components/pages/reports/ReportForm.js new file mode 100644 index 00000000..cdf47eab --- /dev/null +++ b/components/pages/reports/ReportForm.js @@ -0,0 +1,28 @@ +import useMessages from 'hooks/useMessages'; +import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics'; + +export function FunnelForm() { + const { formatMessage, labels } = useMessages(); + + const handleSubmit = () => {}; + + return ( + <> +
+ + + + + + + + + Save + + +
+ + ); +} + +export default FunnelForm; diff --git a/components/pages/reports/ReportForm.module.css b/components/pages/reports/ReportForm.module.css new file mode 100644 index 00000000..4b12238f --- /dev/null +++ b/components/pages/reports/ReportForm.module.css @@ -0,0 +1,19 @@ +.filter { + min-width: 200px; +} + +.hiddenInput { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} + +.hidden { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} + +.urlFormRow label { + min-width: 80px; +} diff --git a/components/pages/reports/funnel/FunnelChart.js b/components/pages/reports/funnel/FunnelChart.js new file mode 100644 index 00000000..307c78ee --- /dev/null +++ b/components/pages/reports/funnel/FunnelChart.js @@ -0,0 +1,185 @@ +import Chart from 'chart.js/auto'; +import classNames from 'classnames'; +import { colord } from 'colord'; +import HoverTooltip from 'components/common/HoverTooltip'; +import Legend from 'components/metrics/Legend'; +import useLocale from 'hooks/useLocale'; +import useMessages from 'hooks/useMessages'; +import useTheme from 'hooks/useTheme'; +import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants'; +import { formatLongNumber } from 'lib/format'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Loading, StatusLight } from 'react-basics'; +import styles from './FunnelChart.module.css'; + +export function FunnelChart({ + data, + animationDuration = DEFAULT_ANIMATION_DURATION, + stacked = false, + loading = false, + onCreate = () => {}, + onUpdate = () => {}, + className, +}) { + const { formatMessage, labels } = useMessages(); + const canvas = useRef(); + const chart = useRef(null); + const [tooltip, setTooltip] = useState(null); + const { locale } = useLocale(); + const [theme] = useTheme(); + + const datasets = useMemo(() => { + const primaryColor = colord(THEME_COLORS[theme].primary); + return [ + { + label: formatMessage(labels.uniqueVisitors), + data: data, + borderWidth: 1, + hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), + backgroundColor: primaryColor.alpha(0.6).toRgbString(), + borderColor: primaryColor.alpha(0.9).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + ]; + }, [data]); + + const colors = useMemo( + () => ({ + text: THEME_COLORS[theme].gray700, + line: THEME_COLORS[theme].gray200, + }), + [theme], + ); + + const renderYLabel = label => { + return +label > 1000 ? formatLongNumber(label) : label; + }; + + const renderTooltip = useCallback(model => { + const { opacity, labelColors, dataPoints } = model.tooltip; + + if (!dataPoints?.length || !opacity) { + setTooltip(null); + return; + } + + setTooltip( +
+
+ +
+
{dataPoints[0].raw.x}
+
{formatLongNumber(dataPoints[0].raw.y)}
+
+
+
+
, + ); + }, []); + + const getOptions = useCallback(() => { + return { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + external: renderTooltip, + }, + }, + scales: { + x: { + grid: { + display: false, + }, + border: { + color: colors.line, + }, + ticks: { + color: colors.text, + autoSkip: false, + maxRotation: 0, + }, + }, + y: { + type: 'linear', + min: 0, + beginAtZero: true, + stacked, + grid: { + color: colors.line, + }, + border: { + color: colors.line, + }, + ticks: { + color: colors.text, + callback: renderYLabel, + }, + }, + }, + }; + }, [animationDuration, renderTooltip, stacked, colors, locale]); + + const createChart = () => { + Chart.defaults.font.family = 'Inter'; + + const options = getOptions(); + + chart.current = new Chart(canvas.current, { + type: 'bar', + data: { datasets }, + options, + }); + + onCreate(chart.current); + }; + + const updateChart = () => { + setTooltip(null); + + chart.current.data.datasets[0].data = datasets[0].data; + chart.current.data.datasets[0].label = datasets[0].label; + + chart.current.options = getOptions(); + + onUpdate(chart.current); + + chart.current.update(); + }; + + useEffect(() => { + if (datasets) { + if (!chart.current) { + createChart(); + } else { + updateChart(); + } + } + }, [datasets, theme, animationDuration, locale]); + + return ( + <> +
+ {loading && } + +
+ + {tooltip && } + + ); +} + +export default FunnelChart; diff --git a/components/pages/reports/funnel/FunnelChart.module.css b/components/pages/reports/funnel/FunnelChart.module.css new file mode 100644 index 00000000..f071a29e --- /dev/null +++ b/components/pages/reports/funnel/FunnelChart.module.css @@ -0,0 +1,23 @@ +.chart { + position: relative; + height: 400px; + overflow: hidden; +} + +.tooltip { + display: flex; + flex-direction: column; + gap: 10px; +} + +.tooltip .value { + display: flex; + flex-direction: column; + text-transform: lowercase; +} + +@media only screen and (max-width: 992px) { + .chart { + /*height: 200px;*/ + } +} diff --git a/components/pages/reports/funnel/FunnelForm.js b/components/pages/reports/funnel/FunnelForm.js new file mode 100644 index 00000000..30edcc56 --- /dev/null +++ b/components/pages/reports/funnel/FunnelForm.js @@ -0,0 +1,118 @@ +import DateFilter from 'components/input/DateFilter'; +import WebsiteSelect from 'components/input/WebsiteSelect'; +import useMessages from 'hooks/useMessages'; +import { parseDateRange } from 'lib/date'; +import { useState } from 'react'; +import { + Button, + Form, + FormButtons, + FormInput, + FormRow, + SubmitButton, + TextField, +} from 'react-basics'; +import styles from './FunnelForm.module.css'; + +export function FunnelForm({ onSearch }) { + const { formatMessage, labels } = useMessages(); + const [dateRange, setDateRange] = useState(''); + const [startAt, setStartAt] = useState(); + const [endAt, setEndAt] = useState(); + const [urls, setUrls] = useState(['/', '/docs/getting-started', '/docs/intall']); + const [websiteId, setWebsiteId] = useState(''); + const [window, setWindow] = useState(60); + + const handleSubmit = async data => { + onSearch(data); + }; + + const handleDateChange = value => { + const { startDate, endDate } = parseDateRange(value); + + setDateRange(value); + setStartAt(startDate.getTime()); + setEndAt(endDate.getTime()); + }; + + const handleAddUrl = () => setUrls([...urls, '']); + + const handleRemoveUrl = i => { + const nextUrls = [...urls]; + nextUrls.splice(i, 1); + setUrls(nextUrls); + }; + + const handleWindowChange = value => setWindow(value.target.value); + + const handleUrlChange = (value, i) => { + const nextUrls = [...urls]; + + nextUrls[i] = value.target.value; + setUrls(nextUrls); + }; + + return ( + <> +
+ + setWebsiteId(value)} /> + + + + + + + + + + + + + + + + + + + + {urls.map((a, i) => ( + + handleUrlChange(value, i)} /> + + + ))} + + + + Query + + +
+ + ); +} + +export default FunnelForm; diff --git a/components/pages/reports/funnel/FunnelForm.module.css b/components/pages/reports/funnel/FunnelForm.module.css new file mode 100644 index 00000000..f251b63f --- /dev/null +++ b/components/pages/reports/funnel/FunnelForm.module.css @@ -0,0 +1,13 @@ +.filter { + min-width: 200px; +} + +.hiddenInput { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} + +.urlFormRow label { + min-width: 80px; +} diff --git a/components/pages/reports/funnel/FunnelPage.js b/components/pages/reports/funnel/FunnelPage.js new file mode 100644 index 00000000..9f4f9bdf --- /dev/null +++ b/components/pages/reports/funnel/FunnelPage.js @@ -0,0 +1,36 @@ +import { useMutation } from '@tanstack/react-query'; +import Page from 'components/layout/Page'; +import PageHeader from 'components/layout/PageHeader'; +import ReportsLayout from 'components/layout/ReportsLayout'; +import useApi from 'hooks/useApi'; +import { useState } from 'react'; +import FunnelChart from './FunnelChart'; +import FunnelTable from './FunnelTable'; +import FunnelForm from './FunnelForm'; + +export default function FunnelPage() { + const { post } = useApi(); + const { mutate } = useMutation(data => post('/reports/funnel', data)); + const [data, setData] = useState([{}]); + const [setFormData] = useState(); + + function handleOnSearch(data) { + setFormData(data); + + mutate(data, { + onSuccess: async data => { + setData(data); + }, + }); + } + + return ( + } header={'test'}> + + + + + + + ); +} diff --git a/components/pages/reports/funnel/FunnelPage.module.css b/components/pages/reports/funnel/FunnelPage.module.css new file mode 100644 index 00000000..aed66b74 --- /dev/null +++ b/components/pages/reports/funnel/FunnelPage.module.css @@ -0,0 +1,10 @@ +.filters { + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--base400); + border-radius: var(--border-radius); + line-height: 32px; + padding: 10px; + overflow: hidden; +} diff --git a/components/pages/reports/funnel/FunnelTable.js b/components/pages/reports/funnel/FunnelTable.js new file mode 100644 index 00000000..27b4bbfc --- /dev/null +++ b/components/pages/reports/funnel/FunnelTable.js @@ -0,0 +1,12 @@ +import DataTable from 'components/metrics/DataTable'; + +export function FunnelTable({ ...props }) { + const { data } = props; + + const tableData = + data?.map(a => ({ x: a.x, y: a.y, z: Math.floor(a.y / data[0].y) * 100 })) || []; + + return ; +} + +export default FunnelTable; diff --git a/components/pages/settings/profile/DateRangeSetting.js b/components/pages/settings/profile/DateRangeSetting.js index 44c3dc42..16db3c07 100644 --- a/components/pages/settings/profile/DateRangeSetting.js +++ b/components/pages/settings/profile/DateRangeSetting.js @@ -7,14 +7,19 @@ import useMessages from 'hooks/useMessages'; export function DateRangeSetting() { const { formatMessage, labels } = useMessages(); const [dateRange, setDateRange] = useDateRange(); - const { startDate, endDate, value } = dateRange; + const { value } = dateRange; const handleChange = value => setDateRange(value); const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); return ( - + ); diff --git a/db/postgresql/migrations/02_report_schema/migration.sql b/db/postgresql/migrations/02_report_schema/migration.sql new file mode 100644 index 00000000..8b2bf0f5 --- /dev/null +++ b/db/postgresql/migrations/02_report_schema/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "user_report" ( + "report_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "website_id" UUID NOT NULL, + "report_name" VARCHAR(200) NOT NULL, + "template_name" VARCHAR(200) NOT NULL, + "parameters" VARCHAR(6000) NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "user_report_pkey" PRIMARY KEY ("report_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_report_report_id_key" ON "user_report"("report_id"); + +-- CreateIndex +CREATE INDEX "user_report_user_id_idx" ON "user_report"("user_id"); diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index b336bce4..318d455d 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -14,11 +14,12 @@ model User { password String @db.VarChar(60) role String @map("role") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) website Website[] teamUser TeamUser[] + Report Report[] @@map("user") } @@ -53,12 +54,13 @@ model Website { resetAt DateTime? @map("reset_at") @db.Timestamptz(6) userId String? @map("user_id") @db.Uuid createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) user User? @relation(fields: [userId], references: [id]) teamWebsite TeamWebsite[] eventData EventData[] + Report Report[] @@index([userId]) @@index([createdAt]) @@ -116,7 +118,7 @@ model Team { name String @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) teamUser TeamUser[] teamWebsite TeamWebsite[] @@ -131,7 +133,7 @@ model TeamUser { userId String @map("user_id") @db.Uuid role String @map("role") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) team Team @relation(fields: [teamId], references: [id]) user User @relation(fields: [userId], references: [id]) @@ -154,3 +156,22 @@ model TeamWebsite { @@index([websiteId]) @@map("team_website") } + +model Report { + id String @id() @unique() @map("report_id") @db.Uuid + userId String @map("user_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + type String @map("type") @db.VarChar(200) + name String @map("name") @db.VarChar(200) + description String @map("description") @db.VarChar(500) + parameters String @map("parameters") @db.VarChar(6000) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id]) + website Website @relation(fields: [websiteId], references: [id]) + + @@index([userId]) + @@index([websiteId]) + @@map("report") +} diff --git a/lib/auth.ts b/lib/auth.ts index 2195ad8f..37dc6acb 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,6 +1,6 @@ -import debug from 'debug'; +import { UserReport } from '@prisma/client'; import redis from '@umami/redis-client'; -import cache from 'lib/cache'; +import debug from 'debug'; import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants'; import { secret } from 'lib/crypto'; import { @@ -10,11 +10,11 @@ import { parseSecureToken, parseToken, } from 'next-basics'; -import { getTeamUser, getTeamUserById } from 'queries'; +import { getTeamUser } from 'queries'; import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite'; import { validate } from 'uuid'; -import { Auth } from './types'; import { loadWebsite } from './query'; +import { Auth } from './types'; const log = debug('umami:auth'); @@ -135,7 +135,34 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) { return false; } -// To-do: Implement when payments are setup. +export async function canViewUserReport(auth: Auth, userReport: UserReport) { + if (auth.user.isAdmin) { + return true; + } + + if ((auth.user.id = userReport.userId)) { + return true; + } + + if (await canViewWebsite(auth, userReport.websiteId)) { + return true; + } + + return false; +} + +export async function canUpdateUserReport(auth: Auth, userReport: UserReport) { + if (auth.user.isAdmin) { + return true; + } + + if ((auth.user.id = userReport.userId)) { + return true; + } + + return false; +} + export async function canCreateTeam({ user }: Auth) { if (user.isAdmin) { return true; @@ -144,7 +171,6 @@ export async function canCreateTeam({ user }: Auth) { return !!user; } -// To-do: Implement when payments are setup. export async function canViewTeam({ user }: Auth, teamId: string) { if (user.isAdmin) { return true; diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 90cf6088..e97be806 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -121,13 +121,36 @@ function getFilterQuery(filters = {}, params = {}) { return query.join('\n'); } +function getFunnelQuery(urls: string[]): { + columnsQuery: string; + conditionQuery: string; + urlParams: { [key: string]: string }; +} { + return urls.reduce( + (pv, cv, i) => { + pv.columnsQuery += `\n,url_path = {url${i}:String}${ + i > 0 && urls[i - 1] ? ` AND referrer_path = {url${i - 1}:String}` : '' + }`; + pv.conditionQuery += `${i > 0 ? ',' : ''} {url${i}:String}`; + pv.urlParams[`url${i}`] = cv; + + return pv; + }, + { + columnsQuery: '', + conditionQuery: '', + urlParams: {}, + }, + ); +} + function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) { return { filterQuery: getFilterQuery(filters, params), }; } -async function rawQuery(query, params = {}) { +async function rawQuery(query, params = {}): Promise { if (process.env.LOG_QUERY) { log('QUERY:\n', query); log('PARAMETERS:\n', params); @@ -135,7 +158,7 @@ async function rawQuery(query, params = {}) { await connect(); - return clickhouse.query(query, { params }).toPromise(); + return clickhouse.query(query, { params }).toPromise() as Promise; } async function findUnique(data) { @@ -168,6 +191,7 @@ export default { getDateFormat, getBetweenDates, getFilterQuery, + getFunnelQuery, getEventDataFilterQuery, parseFilters, findUnique, diff --git a/lib/prisma.ts b/lib/prisma.ts index 0a10d981..fdd8a58d 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -32,6 +32,18 @@ function toUuid(): string { } } +function getAddMinutesQuery(field: string, minutes: number) { + const db = getDatabaseType(process.env.DATABASE_URL); + + if (db === POSTGRESQL) { + return `${field} + interval '${minutes} minute'`; + } + + if (db === MYSQL) { + return `DATE_ADD(${field}, interval ${minutes} minute)`; + } +} + function getDateQuery(field: string, unit: string, timezone?: string): string { const db = getDatabaseType(process.env.DATABASE_URL); @@ -122,6 +134,50 @@ function getFilterQuery(filters = {}, params = []): string { return query.join('\n'); } +function getFunnelQuery( + urls: string[], + windowMinutes: number, + initParamLength = 3, +): { + levelQuery: string; + sumQuery: string; + urlFilterQuery: string; +} { + return urls.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const start = i > 0 ? ',' : ''; + + if (levelNumber >= 2) { + pv.levelQuery += `\n + , level${levelNumber} AS ( + select cl.*, + l0.created_at level_${levelNumber}_created_at, + l0.url_path as level_${levelNumber}_url + from level${i} cl + left join level0 l0 + on cl.session_id = l0.session_id + and l0.created_at between cl.level_${i}_created_at + and ${getAddMinutesQuery(`cl.level_${i}_created_at`, windowMinutes)} + and l0.referrer_path = $${i + initParamLength} + and l0.url_path = $${levelNumber + initParamLength} + )`; + } + + pv.sumQuery += `\n${start}SUM(CASE WHEN level_${levelNumber}_url is not null THEN 1 ELSE 0 END) AS level${levelNumber}`; + + pv.urlFilterQuery += `\n${start}$${levelNumber + initParamLength} `; + + return pv; + }, + { + levelQuery: '', + sumQuery: '', + urlFilterQuery: '', + }, + ); +} + function parseFilters( filters: { [key: string]: any } = {}, params = [], @@ -152,9 +208,11 @@ async function rawQuery(query: string, params: never[] = []): Promise { export default { ...prisma, + getAddMinutesQuery, getDateQuery, getTimestampInterval, getFilterQuery, + getFunnelQuery, getEventDataFilterQuery, toUuid, parseFilters, diff --git a/pages/api/reports/[id].ts b/pages/api/reports/[id].ts new file mode 100644 index 00000000..42002d18 --- /dev/null +++ b/pages/api/reports/[id].ts @@ -0,0 +1,60 @@ +import { canUpdateUserReport, canViewUserReport } from 'lib/auth'; +import { useAuth, useCors } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getUserReportById, updateUserReport } from 'queries'; + +export interface UserReportRequestQuery { + id: string; +} + +export interface UserReportRequestBody { + websiteId: string; + reportName: string; + templateName: string; + parameters: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + if (req.method === 'GET') { + const { id: userReportId } = req.query; + + const data = await getUserReportById(userReportId); + + if (!(await canViewUserReport(req.auth, data))) { + return unauthorized(res); + } + + return ok(res, data); + } + + if (req.method === 'POST') { + const { id: userReportId } = req.query; + + const data = await getUserReportById(userReportId); + + if (!(await canUpdateUserReport(req.auth, data))) { + return unauthorized(res); + } + + const updated = await updateUserReport( + { + ...req.body, + }, + { + id: userReportId, + }, + ); + + return ok(res, updated); + } + + return methodNotAllowed(res); +}; diff --git a/pages/api/reports/funnel.ts b/pages/api/reports/funnel.ts new file mode 100644 index 00000000..6e3eb602 --- /dev/null +++ b/pages/api/reports/funnel.ts @@ -0,0 +1,50 @@ +import { canViewWebsite } from 'lib/auth'; +import { useCors, useAuth } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { ok, methodNotAllowed, unauthorized } from 'next-basics'; +import { getPageviewFunnel } from 'queries'; + +export interface FunnelRequestBody { + websiteId: string; + urls: string[]; + window: number; + startAt: number; + endAt: number; +} + +export interface FunnelResponse { + urls: string[]; + window: number; + startAt: number; + endAt: number; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + if (req.method === 'POST') { + const { websiteId, urls, window, startAt, endAt } = req.body; + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = await getPageviewFunnel(websiteId, { + startDate, + endDate, + urls, + windowMinutes: +window, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts new file mode 100644 index 00000000..e5855355 --- /dev/null +++ b/pages/api/reports/index.ts @@ -0,0 +1,43 @@ +import { uuid } from 'lib/crypto'; +import { useAuth, useCors } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok } from 'next-basics'; +import { createUserReport, getUserReports } from 'queries'; + +export interface UserReportRequestBody { + websiteId: string; + reportName: string; + templateName: string; + parameters: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + const { + user: { id: userId }, + } = req.auth; + + if (req.method === 'GET') { + const data = await getUserReports(userId); + + return ok(res, data); + } + + if (req.method === 'POST') { + const data = await createUserReport({ + id: uuid(), + userId, + ...req.body, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/pages/reports/funnel.js b/pages/reports/funnel.js new file mode 100644 index 00000000..3ba11306 --- /dev/null +++ b/pages/reports/funnel.js @@ -0,0 +1,13 @@ +import AppLayout from 'components/layout/AppLayout'; +import FunnelPage from 'components/pages/reports/funnel/FunnelPage'; +import useMessages from 'hooks/useMessages'; + +export default function Funnel() { + const { formatMessage, labels } = useMessages(); + + return ( + + + + ); +} diff --git a/queries/admin/user.ts b/queries/admin/user.ts index a81a76ef..b7f452c7 100644 --- a/queries/admin/user.ts +++ b/queries/admin/user.ts @@ -210,6 +210,20 @@ export async function deleteUser( }, }, }), + client.userReport.deleteMany({ + where: { + OR: [ + { + websiteId: { + in: websiteIds, + }, + }, + { + userId, + }, + ], + }, + }), cloudMode ? client.website.updateMany({ data: { diff --git a/queries/admin/userReport.ts b/queries/admin/userReport.ts new file mode 100644 index 00000000..d31b512e --- /dev/null +++ b/queries/admin/userReport.ts @@ -0,0 +1,37 @@ +import { Prisma, UserReport } from '@prisma/client'; +import prisma from 'lib/prisma'; + +export async function createUserReport( + data: Prisma.UserReportUncheckedCreateInput, +): Promise { + return prisma.client.userReport.create({ data }); +} + +export async function getUserReportById(userReportId: string): Promise { + return prisma.client.userReport.findUnique({ + where: { + id: userReportId, + }, + }); +} + +export async function getUserReports(userId: string): Promise { + return prisma.client.userReport.findMany({ + where: { + userId, + }, + }); +} + +export async function updateUserReport( + data: Prisma.UserReportUpdateInput, + where: Prisma.UserReportWhereUniqueInput, +): Promise { + return prisma.client.userReport.update({ data, where }); +} + +export async function deleteUserReport( + where: Prisma.UserReportWhereUniqueInput, +): Promise { + return prisma.client.userReport.delete({ where }); +} diff --git a/queries/admin/website.ts b/queries/admin/website.ts index f5ce5739..e6d53fce 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -92,6 +92,11 @@ export async function deleteWebsite( websiteId, }, }), + client.userReport.deleteMany({ + where: { + websiteId, + }, + }), cloudMode ? prisma.client.website.update({ data: { diff --git a/queries/analytics/pageview/getPageviewFunnel.ts b/queries/analytics/pageview/getPageviewFunnel.ts new file mode 100644 index 00000000..ef62e526 --- /dev/null +++ b/queries/analytics/pageview/getPageviewFunnel.ts @@ -0,0 +1,115 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; + +export async function getPageviewFunnel( + ...args: [ + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +): Promise< + { + x: string; + y: number; + }[] +> { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery, getFunnelQuery, toUuid } = prisma; + const { levelQuery, sumQuery, urlFilterQuery } = getFunnelQuery(urls, windowMinutes); + + const params: any = [websiteId, startDate, endDate, ...urls]; + + return rawQuery( + `WITH level0 AS ( + select session_id, url_path, referrer_path, created_at + from website_event + where url_path in (${urlFilterQuery}) + and website_id = $1${toUuid()} + and created_at between $2 and $3 + ),level1 AS ( + select session_id, url_path as level_1_url, created_at as level_1_created_at + from level0 + where url_path = $4 + )${levelQuery} + + SELECT ${sumQuery} + from level3; + `, + params, + ).then((a: { [key: string]: number }) => { + return urls.map((b, i) => ({ x: b, y: a[`level${i + 1}`] || 0 })); + }); +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +): Promise< + { + x: string; + y: number; + }[] +> { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery, getBetweenDates, getFunnelQuery } = clickhouse; + const { columnsQuery, conditionQuery, urlParams } = getFunnelQuery(urls); + + const params = { + websiteId, + window: windowMinutes * 60, + ...urlParams, + }; + + return rawQuery<{ level: number; count: number }[]>( + ` + SELECT level, + count(*) AS count + FROM ( + SELECT session_id, + windowFunnel({window:UInt32}, 'strict_order') + ( + created_at + ${columnsQuery} + ) AS level + FROM website_event + WHERE website_id = {websiteId:UUID} + and ${getBetweenDates('created_at', startDate, endDate)} + AND (url_path in [${conditionQuery}]) + GROUP BY 1 + ) + GROUP BY level + ORDER BY level ASC; + `, + params, + ).then(results => { + return urls.map((a, i) => ({ + x: a, + y: results[i + 1]?.count || 0, + })); + }); +} diff --git a/queries/index.js b/queries/index.js index d87d5dd5..e565df25 100644 --- a/queries/index.js +++ b/queries/index.js @@ -1,6 +1,7 @@ export * from './admin/team'; export * from './admin/teamUser'; export * from './admin/user'; +export * from './admin/userReport'; export * from './admin/website'; export * from './analytics/event/getEventMetrics'; export * from './analytics/event/getEventUsage'; @@ -8,6 +9,7 @@ export * from './analytics/event/getEvents'; export * from './analytics/eventData/getEventData'; export * from './analytics/eventData/getEventDataUsage'; export * from './analytics/event/saveEvent'; +export * from './analytics/pageview/getPageviewFunnel'; export * from './analytics/pageview/getPageviewMetrics'; export * from './analytics/pageview/getPageviewStats'; export * from './analytics/session/createSession'; diff --git a/yarn.lock b/yarn.lock index 41cca434..1f9ea77a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5500,6 +5500,11 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +funnel-graph-js@^1.3.7: + version "1.4.2" + resolved "https://registry.yarnpkg.com/funnel-graph-js/-/funnel-graph-js-1.4.2.tgz#b82150189e8afa59104d881d5dcf55a28d715342" + integrity sha512-9bnmcBve7RDH9dTF9BLuUpuisKkDka3yrfhs+Z/106ZgJvqIse1RfKQWjW+QdAlTrZqC9oafen7t/KuJKv9ohA== + generic-names@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"