mirror of
https://github.com/umami-software/umami.git
synced 2026-02-07 06:07:17 +01:00
New goals page. Upgraded prisma.
This commit is contained in:
parent
99330a1a4d
commit
49bcbfd7f9
65 changed files with 769 additions and 1195 deletions
|
|
@ -9,8 +9,7 @@ import {
|
|||
Grid2X2,
|
||||
Settings,
|
||||
} from '@/components/icons';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import useGlobalState from '@/components/hooks/useGlobalState';
|
||||
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
||||
|
||||
export function SideNav(props: any) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
|
||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
|
||||
|
||||
export function ReportDeleteButton({
|
||||
reportId,
|
||||
|
|
@ -13,11 +14,7 @@ export function ReportDeleteButton({
|
|||
onDelete?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { del, useMutation } = useApi();
|
||||
const { mutate, isPending, error } = useMutation({
|
||||
mutationFn: reportId => del(`/reports/${reportId}`),
|
||||
});
|
||||
const { touch } = useModified();
|
||||
const { mutate, isPending, error, touch } = useDeleteQuery(`/reports/${reportId}`);
|
||||
|
||||
const handleConfirm = (close: () => void) => {
|
||||
mutate(reportId as any, {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function InsightsParameters() {
|
|||
};
|
||||
|
||||
return (
|
||||
<Form values={parameters} onSubmit={handleSubmit} gap="6">
|
||||
<Form values={parameters} onSubmit={handleSubmit}>
|
||||
<BaseParameters allowWebsiteSelect={!id} />
|
||||
{parametersSelected && <FieldParameters />}
|
||||
{parametersSelected && <FilterParameters />}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
id: 'journeys',
|
||||
label: formatMessage(labels.journey),
|
||||
icon: <Path />,
|
||||
path: '/goals',
|
||||
path: '/journeys',
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
|
|
@ -106,7 +106,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
<Link key={id} href={renderTeamUrl(`/websites/${websiteId}${path}`)}>
|
||||
<NavMenuItem isSelected={isSelected}>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon style={{ fill: 'currentcolor' }}>{icon}</Icon>
|
||||
<Icon>{icon}</Icon>
|
||||
<Text>{label}</Text>
|
||||
</Row>
|
||||
</NavMenuItem>
|
||||
|
|
|
|||
171
src/app/(main)/websites/[websiteId]/goals/Goal.tsx
Normal file
171
src/app/(main)/websites/[websiteId]/goals/Goal.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Row,
|
||||
Column,
|
||||
Text,
|
||||
Icon,
|
||||
Button,
|
||||
MenuTrigger,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
ProgressBar,
|
||||
Dialog,
|
||||
Modal,
|
||||
AlertDialog,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { Edit, More, Trash, File, Lightning, User, Eye } from '@/components/icons';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { GoalAddForm } from '@/app/(main)/websites/[websiteId]/goals/GoalAddForm';
|
||||
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
|
||||
|
||||
export interface GoalProps {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parameters: {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
websiteId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export type GoalData = { num: number; total: number };
|
||||
|
||||
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
||||
...parameters,
|
||||
websiteId,
|
||||
dateRange: {
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
});
|
||||
const isPage = parameters?.type === 'page';
|
||||
|
||||
return (
|
||||
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
||||
<Grid gap>
|
||||
<Grid columns="1fr auto" gap>
|
||||
<Column gap>
|
||||
<Row>
|
||||
<Text size="4" weight="bold">
|
||||
{name}
|
||||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
<Column>
|
||||
<ActionsButton id={id} websiteId={websiteId} />
|
||||
</Column>
|
||||
</Grid>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted">
|
||||
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
|
||||
</Text>
|
||||
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
|
||||
<Text>{parameters.value}</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>{isPage ? <Eye /> : <User />}</Icon>
|
||||
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
|
||||
data?.num,
|
||||
)} / ${formatLongNumber(data?.total)}`}</Text>
|
||||
</Row>
|
||||
</Row>
|
||||
<Row alignItems="center" gap>
|
||||
<ProgressBar
|
||||
value={data?.num || 0}
|
||||
minValue={0}
|
||||
maxValue={data?.total || 1}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Text weight="bold" size="7">
|
||||
{data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
|
||||
</Text>
|
||||
</Row>
|
||||
</Grid>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const ActionsButton = ({ id, websiteId }: { id: string; websiteId: string }) => {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const { mutate, touch } = useDeleteQuery(`/reports/${id}`);
|
||||
|
||||
const handleAction = (id: any) => {
|
||||
if (id === 'edit') {
|
||||
setShowEdit(true);
|
||||
} else if (id === 'delete') {
|
||||
setShowDelete(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowEdit(false);
|
||||
setShowDelete(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
mutate(null, {
|
||||
onSuccess: async () => {
|
||||
touch(`goals`);
|
||||
setShowDelete(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<More />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom">
|
||||
<Menu onAction={handleAction}>
|
||||
<MenuItem id="edit">
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</MenuItem>
|
||||
<MenuItem id="delete">
|
||||
<Icon>
|
||||
<Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<Modal isOpen={showEdit || showDelete} isDismissable={true}>
|
||||
{showEdit && (
|
||||
<Dialog variant="modal" style={{ minHeight: 375, minWidth: 300 }}>
|
||||
<GoalAddForm id={id} websiteId={websiteId} onClose={handleClose} />
|
||||
</Dialog>
|
||||
)}
|
||||
{showDelete && (
|
||||
<AlertDialog
|
||||
title={formatMessage(labels.delete)}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={handleClose}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, MenuTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
|
||||
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { GoalAddForm } from './GoalAddForm';
|
||||
import { Plus } from '@/components/icons';
|
||||
|
|
@ -7,7 +7,7 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
|||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<MenuTrigger>
|
||||
<DialogTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Plus />
|
||||
|
|
@ -15,10 +15,10 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
|||
<Text>{formatMessage(labels.addGoal)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog variant="modal" title={formatMessage(labels.addGoal)}>
|
||||
<Dialog variant="modal" title={formatMessage(labels.addGoal)} style={{ width: '400px' }}>
|
||||
{({ close }) => <GoalAddForm websiteId={websiteId} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</MenuTrigger>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,72 +2,116 @@ import {
|
|||
Form,
|
||||
FormField,
|
||||
TextField,
|
||||
Select,
|
||||
Grid,
|
||||
FormButtons,
|
||||
FormSubmitButton,
|
||||
Button,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Text,
|
||||
Icon,
|
||||
Loading,
|
||||
} from '@umami/react-zen';
|
||||
import { useApi, useMessages } from '@/components/hooks';
|
||||
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
|
||||
import { File, Lightning } from '@/components/icons';
|
||||
|
||||
const defaultValues = {
|
||||
name: '',
|
||||
type: 'page',
|
||||
value: '',
|
||||
};
|
||||
|
||||
export function GoalAddForm({
|
||||
id,
|
||||
websiteId,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
id?: string;
|
||||
websiteId: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { touch } = useModified();
|
||||
const { post, useMutation } = useApi();
|
||||
const { data } = useReportQuery(id);
|
||||
const { mutate, error, isPending } = useMutation({
|
||||
mutationFn: (data: any) => post('/websites', { ...data }),
|
||||
mutationFn: (params: any) => post(`/websites/${websiteId}/goals`, params),
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
mutate(
|
||||
{ id, ...data },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
touch('goals');
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const items = [
|
||||
{ id: 'page', label: formatMessage(labels.page) },
|
||||
{ id: 'event', label: formatMessage(labels.event) },
|
||||
];
|
||||
if (id && !data) {
|
||||
return <Loading position="page" icon="dots" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={error?.message}>
|
||||
{websiteId}
|
||||
<FormField
|
||||
name="name"
|
||||
label={formatMessage(labels.name)}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="type"
|
||||
label={formatMessage(labels.type)}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<Select items={items} defaultValue="page" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="value"
|
||||
label={formatMessage(labels.value)}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField />
|
||||
</FormField>
|
||||
<FormButtons>
|
||||
<Button onPress={onClose} isDisabled={isPending}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton isDisabled={false}>{formatMessage(labels.add)}</FormSubmitButton>
|
||||
</FormButtons>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
error={error?.message}
|
||||
defaultValues={data?.parameters || defaultValues}
|
||||
>
|
||||
{({ watch }) => {
|
||||
const watchType = watch('type');
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
name="type"
|
||||
label={formatMessage(labels.type)}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<RadioGroup orientation="horizontal" variant="box">
|
||||
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
||||
<Radio value="page">
|
||||
<Icon>
|
||||
<File />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.page)}</Text>
|
||||
</Radio>
|
||||
<Radio value="event">
|
||||
<Icon>
|
||||
<Lightning />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.event)}</Text>
|
||||
</Radio>
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="name"
|
||||
label={formatMessage(labels.name)}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="value"
|
||||
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
||||
rules={{ required: formatMessage(labels.required) }}
|
||||
>
|
||||
<TextField />
|
||||
</FormField>
|
||||
<FormButtons>
|
||||
<Button onPress={onClose} isDisabled={isPending}>
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton>{formatMessage(id ? labels.save : labels.add)}</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Grid, Loading } from '@umami/react-zen';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { Goal } from './Goal';
|
||||
import { GoalAddButton } from './GoalAddButton';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { WebsiteControls } from '../WebsiteControls';
|
||||
import { useDateRange, useGoalsQuery } from '@/components/hooks';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function GoalsPage({ websiteId }: { websiteId: string }) {
|
||||
const { result } = useGoalsQuery({ websiteId });
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange(websiteId);
|
||||
|
||||
if (!result) {
|
||||
return <Loading position="page" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<SectionHeader>
|
||||
<GoalAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
</Column>
|
||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
||||
<SectionHeader>
|
||||
<GoalAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
{result?.data?.map((goal: any) => (
|
||||
<Panel key={goal.id}>
|
||||
<Goal {...goal} reportId={goal.id} startDate={startDate} endDate={endDate} />
|
||||
</Panel>
|
||||
))}
|
||||
</Grid>
|
||||
</LoadingPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,12 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) {
|
|||
{hour > 0 && (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Focusable>
|
||||
<div
|
||||
className={styles.block}
|
||||
style={{ opacity: pct, transform: `scale(${pct})` }}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={styles.block}
|
||||
style={{ opacity: pct, transform: `scale(${pct})` }}
|
||||
/>
|
||||
</div>
|
||||
</Focusable>
|
||||
<Tooltip placement="right">{`${formatMessage(
|
||||
labels.visitors,
|
||||
|
|
|
|||
|
|
@ -2,34 +2,28 @@ import { z } from 'zod';
|
|||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { unauthorized, json } from '@/lib/response';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { getGoals } from '@/queries/sql/reports/getGoals';
|
||||
import { reportParms } from '@/lib/schema';
|
||||
import { getGoal } from '@/queries/sql/reports/getGoal';
|
||||
import { filterParams, reportParms } from '@/lib/schema';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
...reportParms,
|
||||
goals: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
type: z.string().regex(/url|event|event-data/),
|
||||
value: z.string(),
|
||||
goal: z.coerce.number(),
|
||||
operator: z
|
||||
.string()
|
||||
.regex(/count|sum|average/)
|
||||
.optional(),
|
||||
property: z.string().optional(),
|
||||
})
|
||||
.refine(data => {
|
||||
if (data['type'] === 'event-data') {
|
||||
return data['operator'] && data['property'];
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
const schema = z
|
||||
.object({
|
||||
...reportParms,
|
||||
...filterParams,
|
||||
type: z.enum(['page', 'event']),
|
||||
value: z.string(),
|
||||
operator: z
|
||||
.string()
|
||||
.regex(/count|sum|average/)
|
||||
.optional(),
|
||||
property: z.string().optional(),
|
||||
})
|
||||
.refine(data => {
|
||||
if (data['type'] === 'event' && data['property']) {
|
||||
return data['operator'] && data['property'];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
|
|
@ -39,18 +33,24 @@ export async function POST(request: Request) {
|
|||
|
||||
const {
|
||||
websiteId,
|
||||
type,
|
||||
value,
|
||||
property,
|
||||
operator,
|
||||
dateRange: { startDate, endDate },
|
||||
goals,
|
||||
} = body;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getGoals(websiteId, {
|
||||
const data = await getGoal(websiteId, {
|
||||
type,
|
||||
value,
|
||||
property,
|
||||
operator,
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
goals,
|
||||
});
|
||||
|
||||
return json(data);
|
||||
|
|
|
|||
94
src/app/api/websites/[websiteId]/goals/route.ts
Normal file
94
src/app/api/websites/[websiteId]/goals/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { z } from 'zod';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { unauthorized, json, ok } from '@/lib/response';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { getReports, createReport, updateReport } from '@/queries';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const { auth, query, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { page, search, pageSize } = query;
|
||||
const filters = {
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
};
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getReports(
|
||||
{
|
||||
where: {
|
||||
websiteId,
|
||||
type: 'goals',
|
||||
},
|
||||
},
|
||||
filters,
|
||||
).then(result => {
|
||||
result.data = result.data.map(report => {
|
||||
report.parameters = JSON.parse(report.parameters);
|
||||
|
||||
return report;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string(),
|
||||
type: z.enum(['page', 'event']),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id, name, type, value } = body;
|
||||
|
||||
if (id) {
|
||||
await updateReport(id, {
|
||||
name,
|
||||
parameters: JSON.stringify({ name, type, value }),
|
||||
});
|
||||
} else {
|
||||
await createReport({
|
||||
id: uuid(),
|
||||
userId: auth.user.id,
|
||||
websiteId,
|
||||
type: 'goals',
|
||||
name,
|
||||
description: '',
|
||||
parameters: JSON.stringify({ name, type, value }),
|
||||
});
|
||||
}
|
||||
|
||||
return ok();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue