diff --git a/src/app/(main)/reports/[reportId]/ReportPage.tsx b/src/app/(main)/reports/[reportId]/ReportPage.tsx
index 7ecebd31..f28942b7 100644
--- a/src/app/(main)/reports/[reportId]/ReportPage.tsx
+++ b/src/app/(main)/reports/[reportId]/ReportPage.tsx
@@ -4,6 +4,7 @@ import EventDataReport from '../event-data/EventDataReport';
import InsightsReport from '../insights/InsightsReport';
import RetentionReport from '../retention/RetentionReport';
import UTMReport from '../utm/UTMReport';
+import GoalReport from '../goals/GoalsReport';
import { useReport } from 'components/hooks';
const reports = {
@@ -12,6 +13,7 @@ const reports = {
insights: InsightsReport,
retention: RetentionReport,
utm: UTMReport,
+ goals: GoalReport,
};
export default function ReportPage({ reportId }: { reportId: string }) {
diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx
index 1bd84aec..ac29f5c3 100644
--- a/src/app/(main)/reports/create/ReportTemplates.tsx
+++ b/src/app/(main)/reports/create/ReportTemplates.tsx
@@ -37,6 +37,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/utm'),
icon: ,
},
+ {
+ title: formatMessage(labels.goals),
+ description: formatMessage(labels.goalsDescription),
+ url: renderTeamUrl('/reports/goals'),
+ icon: ,
+ },
];
return (
diff --git a/src/app/(main)/reports/goals/GoalsAddForm.module.css b/src/app/(main)/reports/goals/GoalsAddForm.module.css
new file mode 100644
index 00000000..a254ff08
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsAddForm.module.css
@@ -0,0 +1,7 @@
+.dropdown {
+ width: 140px;
+}
+
+.input {
+ width: 200px;
+}
diff --git a/src/app/(main)/reports/goals/GoalsAddForm.tsx b/src/app/(main)/reports/goals/GoalsAddForm.tsx
new file mode 100644
index 00000000..fdfa7aad
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsAddForm.tsx
@@ -0,0 +1,95 @@
+import { useMessages } from 'components/hooks';
+import { useState } from 'react';
+import { Button, Dropdown, Flexbox, FormRow, Item, TextField } from 'react-basics';
+import styles from './GoalsAddForm.module.css';
+
+export function GoalsAddForm({
+ type: defaultType = 'url',
+ value: defaultValue = '',
+ goal: defaultGoal = 10,
+ onChange,
+}: {
+ type?: string;
+ value?: string;
+ goal?: number;
+ onChange?: (step: { type: string; value: string; goal: number }) => void;
+}) {
+ const [type, setType] = useState(defaultType);
+ const [value, setValue] = useState(defaultValue);
+ const [goal, setGoal] = useState(defaultGoal);
+ const { formatMessage, labels } = useMessages();
+ const items = [
+ { label: formatMessage(labels.url), value: 'url' },
+ { label: formatMessage(labels.event), value: 'event' },
+ ];
+ const isDisabled = !type || !value;
+
+ const handleSave = () => {
+ onChange({ type, value, goal });
+ setValue('');
+ setGoal(10);
+ };
+
+ const handleChange = (e, set) => {
+ set(e.target.value);
+ };
+
+ const handleKeyDown = e => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ handleSave();
+ }
+ };
+
+ const renderTypeValue = (value: any) => {
+ return items.find(item => item.value === value)?.label;
+ };
+
+ return (
+
+
+
+ setType(value)}
+ >
+ {({ value, label }) => {
+ return - {label}
;
+ }}
+
+ handleChange(e, setValue)}
+ autoFocus={true}
+ autoComplete="off"
+ onKeyDown={handleKeyDown}
+ />
+
+
+
+
+ handleChange(e, setGoal)}
+ autoFocus={true}
+ autoComplete="off"
+ onKeyDown={handleKeyDown}
+ />
+ .
+
+
+
+
+
+
+ );
+}
+
+export default GoalsAddForm;
diff --git a/src/app/(main)/reports/goals/GoalsChart.module.css b/src/app/(main)/reports/goals/GoalsChart.module.css
new file mode 100644
index 00000000..c9f158d2
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsChart.module.css
@@ -0,0 +1,87 @@
+.chart {
+ display: grid;
+}
+
+.num {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ width: 50px;
+ height: 50px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--base800);
+ background: var(--base100);
+ z-index: 1;
+}
+
+.step {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ column-gap: 30px;
+ position: relative;
+ padding-bottom: 60px;
+}
+
+.card {
+ display: grid;
+ gap: 20px;
+ margin-top: 14px;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.bar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ background: var(--base900);
+ height: 30px;
+ border-radius: 5px;
+ overflow: hidden;
+ position: relative;
+}
+
+.label {
+ color: var(--base600);
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.track {
+ background-color: var(--base100);
+ border-radius: 5px;
+}
+
+.item {
+ font-size: 20px;
+ color: var(--base900);
+ font-weight: 700;
+}
+
+.metric {
+ color: var(--base700);
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ margin: 10px 0;
+ text-transform: lowercase;
+}
+
+.visitors {
+ color: var(--base900);
+ font-size: 24px;
+ font-weight: 900;
+ margin-right: 10px;
+}
+
+.percent {
+ font-size: 20px;
+ font-weight: 700;
+ align-self: flex-end;
+}
diff --git a/src/app/(main)/reports/goals/GoalsChart.tsx b/src/app/(main)/reports/goals/GoalsChart.tsx
new file mode 100644
index 00000000..7cebbe21
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsChart.tsx
@@ -0,0 +1,49 @@
+import { useContext } from 'react';
+import classNames from 'classnames';
+import { useMessages } from 'components/hooks';
+import { ReportContext } from '../[reportId]/Report';
+import { formatLongNumber } from 'lib/format';
+import styles from './GoalsChart.module.css';
+
+export function GoalsChart({ className }: { className?: string; isLoading?: boolean }) {
+ const { report } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+
+ const { data } = report || {};
+
+ return (
+
+ {data?.map(({ type, value, goal, result }, index: number) => {
+ return (
+
+
{index + 1}
+
+
+
+ {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}
+
+ {value}
+
+
+
+ {formatLongNumber(result)}
+ {formatMessage(labels.visitors)}
+
+
+ {formatLongNumber(goal)}
+ {formatMessage(labels.goal)}
+
+
{(result / goal).toFixed(2)}%
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+export default GoalsChart;
diff --git a/src/app/(main)/reports/goals/GoalsParameters.module.css b/src/app/(main)/reports/goals/GoalsParameters.module.css
new file mode 100644
index 00000000..421433f0
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsParameters.module.css
@@ -0,0 +1,26 @@
+.item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+}
+
+.type {
+ color: var(--base700);
+}
+
+.value {
+ display: flex;
+ align-self: center;
+ gap: 20px;
+}
+
+.goal {
+ color: var(--blue900);
+ background-color: var(--blue100);
+ font-size: 12px;
+ font-weight: 900;
+ padding: 2px 8px;
+ border-radius: 5px;
+ white-space: nowrap;
+}
diff --git a/src/app/(main)/reports/goals/GoalsParameters.tsx b/src/app/(main)/reports/goals/GoalsParameters.tsx
new file mode 100644
index 00000000..b4743d2d
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsParameters.tsx
@@ -0,0 +1,123 @@
+import { useMessages } from 'components/hooks';
+import Icons from 'components/icons';
+import { formatNumber } from 'lib/format';
+import { useContext } from 'react';
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormRow,
+ Icon,
+ Popup,
+ PopupTrigger,
+ SubmitButton,
+} from 'react-basics';
+import BaseParameters from '../[reportId]/BaseParameters';
+import ParameterList from '../[reportId]/ParameterList';
+import PopupForm from '../[reportId]/PopupForm';
+import { ReportContext } from '../[reportId]/Report';
+import GoalsAddForm from './GoalsAddForm';
+import styles from './GoalsParameters.module.css';
+
+export function GoalsParameters() {
+ const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+
+ const { id, parameters } = report || {};
+ const { websiteId, dateRange, goals } = parameters || {};
+ const queryDisabled = !websiteId || !dateRange || goals?.length < 1;
+
+ const handleSubmit = (data: any, e: any) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (!queryDisabled) {
+ runReport(data);
+ }
+ };
+
+ const handleAddGoals = (goal: { type: string; value: string }) => {
+ updateReport({ parameters: { goals: parameters.goals.concat(goal) } });
+ };
+
+ const handleUpdateGoals = (
+ close: () => void,
+ index: number,
+ goal: { type: string; value: string },
+ ) => {
+ const goals = [...parameters.goals];
+ goals[index] = goal;
+ updateReport({ parameters: { goals } });
+ close();
+ };
+
+ const handleRemoveGoals = (index: number) => {
+ const goals = [...parameters.goals];
+ delete goals[index];
+ updateReport({ parameters: { goals: goals.filter(n => n) } });
+ };
+
+ const AddGoalsButton = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+ );
+}
+
+export default GoalsParameters;
diff --git a/src/app/(main)/reports/goals/GoalsReport.module.css b/src/app/(main)/reports/goals/GoalsReport.module.css
new file mode 100644
index 00000000..aed66b74
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsReport.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/src/app/(main)/reports/goals/GoalsReport.tsx b/src/app/(main)/reports/goals/GoalsReport.tsx
new file mode 100644
index 00000000..d6171c31
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsReport.tsx
@@ -0,0 +1,27 @@
+import GoalsChart from './GoalsChart';
+import GoalsParameters from './GoalsParameters';
+import Report from '../[reportId]/Report';
+import ReportHeader from '../[reportId]/ReportHeader';
+import ReportMenu from '../[reportId]/ReportMenu';
+import ReportBody from '../[reportId]/ReportBody';
+import Goals from 'assets/funnel.svg';
+import { REPORT_TYPES } from 'lib/constants';
+
+const defaultParameters = {
+ type: REPORT_TYPES.goals,
+ parameters: { goals: [] },
+};
+
+export default function GoalsReport({ reportId }: { reportId?: string }) {
+ return (
+
+ } />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/reports/goals/GoalsReportPage.tsx b/src/app/(main)/reports/goals/GoalsReportPage.tsx
new file mode 100644
index 00000000..cbab8bd0
--- /dev/null
+++ b/src/app/(main)/reports/goals/GoalsReportPage.tsx
@@ -0,0 +1,6 @@
+'use client';
+import GoalReport from './GoalsReport';
+
+export default function GoalReportPage() {
+ return ;
+}
diff --git a/src/app/(main)/reports/goals/page.tsx b/src/app/(main)/reports/goals/page.tsx
new file mode 100644
index 00000000..112ae47c
--- /dev/null
+++ b/src/app/(main)/reports/goals/page.tsx
@@ -0,0 +1,10 @@
+import GoalsReportPage from './GoalsReportPage';
+import { Metadata } from 'next';
+
+export default function () {
+ return ;
+}
+
+export const metadata: Metadata = {
+ title: 'Goals Report',
+};
diff --git a/src/app/(main)/reports/utm/page.tsx b/src/app/(main)/reports/utm/page.tsx
index 12b6cc5b..7fa50660 100644
--- a/src/app/(main)/reports/utm/page.tsx
+++ b/src/app/(main)/reports/utm/page.tsx
@@ -6,5 +6,5 @@ export default function () {
}
export const metadata: Metadata = {
- title: 'UTM Report',
+ title: 'Goals Report',
};
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 7a11aa89..1413549f 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -235,6 +235,13 @@ export const labels = defineMessages({
},
steps: { id: 'label.steps', defaultMessage: 'Steps' },
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
+ goal: { id: 'label.goal', defaultMessage: 'Goal' },
+ goals: { id: 'label.goals', defaultMessage: 'Goals' },
+ goalsDescription: {
+ id: 'label.goals-description',
+ defaultMessage: 'Track your goals for pageviews or events.',
+ },
+ count: { id: 'label.count', defaultMessage: 'Count' },
});
export const messages = defineMessages({
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index c1b9d25f..35634841 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -119,7 +119,10 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio
};
}
-async function rawQuery(query: string, params: Record = {}): Promise {
+async function rawQuery(
+ query: string,
+ params: Record = {},
+): Promise {
if (process.env.LOG_QUERY) {
log('QUERY:\n', query);
log('PARAMETERS:\n', params);
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 0b6f7d26..697a4836 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -111,6 +111,7 @@ export const DATA_TYPES = {
export const REPORT_TYPES = {
funnel: 'funnel',
+ goals: 'goals',
insights: 'insights',
retention: 'retention',
utm: 'utm',
diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts
index db7d0bcc..be2db82f 100644
--- a/src/pages/api/reports/[reportId].ts
+++ b/src/pages/api/reports/[reportId].ts
@@ -27,7 +27,7 @@ const schema: YupRequest = {
websiteId: yup.string().uuid().required(),
type: yup
.string()
- .matches(/funnel|insights|retention|utm/i)
+ .matches(/funnel|insights|retention|utm|goals/i)
.required(),
name: yup.string().max(200).required(),
description: yup.string().max(500),
diff --git a/src/pages/api/reports/goals.ts b/src/pages/api/reports/goals.ts
new file mode 100644
index 00000000..bb766775
--- /dev/null
+++ b/src/pages/api/reports/goals.ts
@@ -0,0 +1,70 @@
+import { canViewWebsite } from 'lib/auth';
+import { useAuth, useCors, useValidate } from 'lib/middleware';
+import { NextApiRequestQueryBody } from 'lib/types';
+import { TimezoneTest } from 'lib/yup';
+import { NextApiResponse } from 'next';
+import { methodNotAllowed, ok, unauthorized } from 'next-basics';
+import { getGoals } from 'queries/analytics/reports/getGoals';
+import * as yup from 'yup';
+
+export interface RetentionRequestBody {
+ websiteId: string;
+ dateRange: { startDate: string; endDate: string; timezone: string };
+ goals: { type: string; value: string; goal: number }[];
+}
+
+const schema = {
+ POST: yup.object().shape({
+ websiteId: yup.string().uuid().required(),
+ dateRange: yup
+ .object()
+ .shape({
+ startDate: yup.date().required(),
+ endDate: yup.date().required(),
+ timezone: TimezoneTest,
+ })
+ .required(),
+ goals: yup
+ .array()
+ .of(
+ yup.object().shape({
+ type: yup.string().required(),
+ value: yup.string().required(),
+ goal: yup.number().required(),
+ }),
+ )
+ .min(1)
+ .required(),
+ }),
+};
+
+export default async (
+ req: NextApiRequestQueryBody,
+ res: NextApiResponse,
+) => {
+ await useCors(req, res);
+ await useAuth(req, res);
+ await useValidate(schema, req, res);
+
+ if (req.method === 'POST') {
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ goals,
+ } = req.body;
+
+ if (!(await canViewWebsite(req.auth, websiteId))) {
+ return unauthorized(res);
+ }
+
+ const data = await getGoals(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ goals,
+ });
+
+ return ok(res, data);
+ }
+
+ return methodNotAllowed(res);
+};
diff --git a/src/pages/api/reports/index.ts b/src/pages/api/reports/index.ts
index d231f0b7..186a1821 100644
--- a/src/pages/api/reports/index.ts
+++ b/src/pages/api/reports/index.ts
@@ -27,7 +27,7 @@ const schema = {
name: yup.string().max(200).required(),
type: yup
.string()
- .matches(/funnel|insights|retention|utm/i)
+ .matches(/funnel|insights|retention|utm|goals/i)
.required(),
description: yup.string().max(500),
parameters: yup
diff --git a/src/queries/analytics/reports/getGoals.ts b/src/queries/analytics/reports/getGoals.ts
new file mode 100644
index 00000000..f275d604
--- /dev/null
+++ b/src/queries/analytics/reports/getGoals.ts
@@ -0,0 +1,225 @@
+import clickhouse from 'lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
+import prisma from 'lib/prisma';
+
+export async function getGoals(
+ ...args: [
+ websiteId: string,
+ criteria: {
+ startDate: Date;
+ endDate: Date;
+ goals: { type: string; value: string; goal: number }[];
+ },
+ ]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ criteria: {
+ startDate: Date;
+ endDate: Date;
+ goals: { type: string; value: string; goal: number }[];
+ },
+): Promise {
+ const { startDate, endDate, goals } = criteria;
+ const { rawQuery } = prisma;
+
+ const hasUrl = goals.some(a => a.type === 'url');
+ const hasEvent = goals.some(a => a.type === 'event');
+
+ function getParameters(goals: { type: string; value: string; goal: number }[]) {
+ const urls = goals
+ .filter(a => a.type === 'url')
+ .reduce((acc, cv, i) => {
+ acc[`${cv.type}${i}`] = cv.value;
+ return acc;
+ }, {});
+
+ const events = goals
+ .filter(a => a.type === 'event')
+ .reduce((acc, cv, i) => {
+ acc[`${cv.type}${i}`] = cv.value;
+ return acc;
+ }, {});
+
+ return {
+ urls: { ...urls, startDate, endDate, websiteId },
+ events: { ...events, startDate, endDate, websiteId },
+ };
+ }
+
+ function getColumns(goals: { type: string; value: string; goal: number }[]) {
+ const urls = goals
+ .filter(a => a.type === 'url')
+ .map((a, i) => `COUNT(CASE WHEN url_path = {{url${i}}} THEN 1 END) AS URL${i}`)
+ .join('\n');
+ const events = goals
+ .filter(a => a.type === 'event')
+ .map((a, i) => `COUNT(CASE WHEN url_path = {{event${i}}} THEN 1 END) AS EVENT${i}`)
+ .join('\n');
+
+ return { urls, events };
+ }
+
+ function getWhere(goals: { type: string; value: string; goal: number }[]) {
+ const urls = goals
+ .filter(a => a.type === 'url')
+ .map((a, i) => `{{url${i}}}`)
+ .join(',');
+ const events = goals
+ .filter(a => a.type === 'event')
+ .map((a, i) => `{{event${i}}}`)
+ .join(',');
+
+ return { urls: `and url_path in (${urls})`, events: `and event_name in (${events})` };
+ }
+
+ const parameters = getParameters(goals);
+ const columns = getColumns(goals);
+ const where = getWhere(goals);
+
+ const urls = hasUrl
+ ? await rawQuery(
+ `
+ select
+ ${columns.urls}
+ from website_event
+ where websiteId = {{websiteId::uuid}}
+ ${where.urls}
+ and created_at between {{startDate}} and {{endDate}}
+ `,
+ parameters.urls,
+ )
+ : [];
+
+ const events = hasEvent
+ ? await rawQuery(
+ `
+ select
+ ${columns.events}
+ from website_event
+ where websiteId = {{websiteId::uuid}}
+ ${where.events}
+ and created_at between {{startDate}} and {{endDate}}
+ `,
+ parameters.events,
+ )
+ : [];
+
+ return [...urls, ...events];
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ criteria: {
+ startDate: Date;
+ endDate: Date;
+ goals: { type: string; value: string; goal: number }[];
+ },
+): Promise<{ type: string; value: string; goal: number; result: number }[]> {
+ const { startDate, endDate, goals } = criteria;
+ const { rawQuery } = clickhouse;
+
+ const urls = goals.filter(a => a.type === 'url');
+ const events = goals.filter(a => a.type === 'event');
+
+ const hasUrl = urls.length > 0;
+ const hasEvent = events.length > 0;
+
+ function getParameters(
+ urls: { type: string; value: string; goal: number }[],
+ events: { type: string; value: string; goal: number }[],
+ ) {
+ const urlParam = urls.reduce((acc, cv, i) => {
+ acc[`${cv.type}${i}`] = cv.value;
+ return acc;
+ }, {});
+
+ const eventParam = events.reduce((acc, cv, i) => {
+ acc[`${cv.type}${i}`] = cv.value;
+ return acc;
+ }, {});
+
+ return {
+ urls: { ...urlParam, startDate, endDate, websiteId },
+ events: { ...eventParam, startDate, endDate, websiteId },
+ };
+ }
+
+ function getColumns(
+ urls: { type: string; value: string; goal: number }[],
+ events: { type: string; value: string; goal: number }[],
+ ) {
+ const urlColumns = urls
+ .map((a, i) => `countIf(url_path = {url${i}:String}) AS URL${i},`)
+ .join('\n')
+ .slice(0, -1);
+ const eventColumns = events
+ .map((a, i) => `countIf(event_name = {event${i}:String}) AS EVENT${i}`)
+ .join('\n')
+ .slice(0, -1);
+
+ return { url: urlColumns, events: eventColumns };
+ }
+
+ function getWhere(
+ urls: { type: string; value: string; goal: number }[],
+ events: { type: string; value: string; goal: number }[],
+ ) {
+ const urlWhere = urls.map((a, i) => `{url${i}:String}`).join(',');
+ const eventWhere = events.map((a, i) => `{event${i}:String}`).join(',');
+
+ return { urls: `and url_path in (${urlWhere})`, events: `and event_name in (${eventWhere})` };
+ }
+
+ const parameters = getParameters(urls, events);
+ const columns = getColumns(urls, events);
+ const where = getWhere(urls, events);
+
+ const urlResults = hasUrl
+ ? await rawQuery(
+ `
+ select
+ ${columns.url}
+ from website_event
+ where website_id = {websiteId:UUID}
+ ${where.urls}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ `,
+ parameters.urls,
+ ).then(a => {
+ const results = a[0];
+
+ return Object.keys(results).map((key, i) => {
+ return { ...urls[i], result: results[key] };
+ });
+ })
+ : [];
+
+ const eventResults = hasEvent
+ ? await rawQuery(
+ `
+ select
+ ${columns.events}
+ from website_event
+ where website_id = {websiteId:UUID}
+ ${where.events}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ `,
+ parameters.events,
+ ).then(a => {
+ const results = a[0];
+
+ return Object.keys(results).map((key, i) => {
+ return { ...events[i], result: results[key] };
+ });
+ })
+ : [];
+
+ return [...urlResults, ...eventResults];
+}