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 (
+ <>
+
+ >
+ );
+}
+
+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 (
+ <>
+
+ >
+ );
+}
+
+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"