Merge branch 'feat/um-285-report-schema' into dev

This commit is contained in:
Brian Cao 2023-05-18 13:21:35 -07:00
commit 40f53e8856
29 changed files with 1007 additions and 14 deletions

View File

@ -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);

View File

@ -0,0 +1,23 @@
import { Column, Row } from 'react-basics';
import styles from './ReportsLayout.module.css';
export function SettingsLayout({ children, filter, header }) {
return (
<>
<Row>{header}</Row>
<Row>
{filter && (
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
<h2>Filters</h2>
{filter}
</Column>
)}
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
{children}
</Column>
</Row>
</>
);
}
export default SettingsLayout;

View File

@ -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;
}
}

View File

@ -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.',

View File

@ -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 (
<>
<Form onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.website)}>
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
<TextField />
</FormInput>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={false}>
Save
</SubmitButton>
</FormButtons>
</Form>
</>
);
}
export default FunnelForm;

View File

@ -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;
}

View File

@ -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(
<div className={styles.tooltip}>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
<div className={styles.value}>
<div>{dataPoints[0].raw.x}</div>
<div>{formatLongNumber(dataPoints[0].raw.y)}</div>
</div>
</StatusLight>
</div>
</div>,
);
}, []);
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 (
<>
<div className={classNames(styles.chart, className)}>
{loading && <Loading position="page" icon="dots" />}
<canvas ref={canvas} />
</div>
<Legend chart={chart.current} />
{tooltip && <HoverTooltip tooltip={tooltip} />}
</>
);
}
export default FunnelChart;

View File

@ -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;*/
}
}

View File

@ -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 (
<>
<Form
values={{
websiteId,
startAt,
endAt,
urls,
window,
}}
onSubmit={handleSubmit}
>
<FormRow label={formatMessage(labels.website)}>
<WebsiteSelect websiteId={websiteId} onSelect={value => setWebsiteId(value)} />
<FormInput name="websiteId" rules={{ required: formatMessage(labels.required) }}>
<TextField value={websiteId} className={styles.hiddenInput} />
</FormInput>
</FormRow>
<FormRow label="Date">
<DateFilter
className={styles.filter}
value={dateRange}
alignment="start"
onChange={handleDateChange}
isF
/>
<FormInput
name="startAt"
className={styles.hiddenInput}
rules={{ required: formatMessage(labels.required) }}
>
<TextField value={startAt} />
</FormInput>
<FormInput name="endAt" rules={{ required: formatMessage(labels.required) }}>
<TextField value={endAt} className={styles.hiddenInput} />
</FormInput>
</FormRow>
<FormRow label="Window (minutes)">
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField value={window} onChange={handleWindowChange} />
</FormInput>
</FormRow>
<Button onClick={handleAddUrl}>Add URL</Button>
{urls.map((a, i) => (
<FormRow className={styles.urlFormRow} key={`url${i}`} label={`URL ${i + 1}`}>
<TextField value={urls[i]} onChange={value => handleUrlChange(value, i)} />
<Button onClick={() => handleRemoveUrl(i)}>Remove URL</Button>
</FormRow>
))}
<FormButtons>
<SubmitButton variant="primary" disabled={false}>
Query
</SubmitButton>
</FormButtons>
</Form>
</>
);
}
export default FunnelForm;

View File

@ -0,0 +1,13 @@
.filter {
min-width: 200px;
}
.hiddenInput {
visibility: hidden;
min-height: 0px;
max-height: 0px;
}
.urlFormRow label {
min-width: 80px;
}

View File

@ -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 (
<ReportsLayout filter={<FunnelForm onSearch={handleOnSearch} />} header={'test'}>
<Page>
<PageHeader title="Funnel Report"></PageHeader>
<FunnelChart data={data} />
<FunnelTable data={data} />
</Page>
</ReportsLayout>
);
}

View File

@ -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;
}

View File

@ -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 <DataTable data={tableData} title="Url" type="device" />;
}
export default FunnelTable;

View File

@ -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 (
<Flexbox gap={10}>
<DateFilter value={value} startDate={startDate} endDate={endDate} onChange={handleChange} />
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);

View File

@ -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");

View File

@ -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")
}

View File

@ -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;

View File

@ -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<T>(query, params = {}): Promise<T> {
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<T>;
}
async function findUnique(data) {
@ -168,6 +191,7 @@ export default {
getDateFormat,
getBetweenDates,
getFilterQuery,
getFunnelQuery,
getEventDataFilterQuery,
parseFilters,
findUnique,

View File

@ -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<any> {
export default {
...prisma,
getAddMinutesQuery,
getDateQuery,
getTimestampInterval,
getFilterQuery,
getFunnelQuery,
getEventDataFilterQuery,
toUuid,
parseFilters,

60
pages/api/reports/[id].ts Normal file
View File

@ -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<UserReportRequestQuery, UserReportRequestBody>,
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);
};

View File

@ -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<any, FunnelRequestBody>,
res: NextApiResponse<FunnelResponse>,
) => {
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);
};

View File

@ -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<any, UserReportRequestBody>,
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);
};

13
pages/reports/funnel.js Normal file
View File

@ -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 (
<AppLayout title={`${formatMessage(labels.settings)} - ${formatMessage(labels.reports)}`}>
<FunnelPage />
</AppLayout>
);
}

View File

@ -210,6 +210,20 @@ export async function deleteUser(
},
},
}),
client.userReport.deleteMany({
where: {
OR: [
{
websiteId: {
in: websiteIds,
},
},
{
userId,
},
],
},
}),
cloudMode
? client.website.updateMany({
data: {

View File

@ -0,0 +1,37 @@
import { Prisma, UserReport } from '@prisma/client';
import prisma from 'lib/prisma';
export async function createUserReport(
data: Prisma.UserReportUncheckedCreateInput,
): Promise<UserReport> {
return prisma.client.userReport.create({ data });
}
export async function getUserReportById(userReportId: string): Promise<UserReport> {
return prisma.client.userReport.findUnique({
where: {
id: userReportId,
},
});
}
export async function getUserReports(userId: string): Promise<UserReport[]> {
return prisma.client.userReport.findMany({
where: {
userId,
},
});
}
export async function updateUserReport(
data: Prisma.UserReportUpdateInput,
where: Prisma.UserReportWhereUniqueInput,
): Promise<UserReport> {
return prisma.client.userReport.update({ data, where });
}
export async function deleteUserReport(
where: Prisma.UserReportWhereUniqueInput,
): Promise<UserReport> {
return prisma.client.userReport.delete({ where });
}

View File

@ -92,6 +92,11 @@ export async function deleteWebsite(
websiteId,
},
}),
client.userReport.deleteMany({
where: {
websiteId,
},
}),
cloudMode
? prisma.client.website.update({
data: {

View File

@ -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,
}));
});
}

View File

@ -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';

View File

@ -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"