Merge remote-tracking branch 'origin/dev' into dev
@ -50,7 +50,8 @@
|
|||||||
"@next/next/no-img-element": "off",
|
"@next/next/no-img-element": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-var-requires": "off"
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
|
"@typescript-eslint/no-empty-interface": "off"
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"React": "writable"
|
"React": "writable"
|
||||||
|
37
components/common/Pager.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import styles from './Pager.module.css';
|
||||||
|
import { Button, Flexbox, Icon, Icons } from 'react-basics';
|
||||||
|
|
||||||
|
export function Pager({ page, pageSize, count, onPageChange, onPageSizeChange }) {
|
||||||
|
const maxPage = Math.ceil(count / pageSize);
|
||||||
|
const lastPage = page === maxPage;
|
||||||
|
const firstPage = page === 1;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = value => {
|
||||||
|
const nextPage = page + value;
|
||||||
|
if (nextPage > 0 && nextPage <= maxPage) {
|
||||||
|
onPageChange(nextPage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox justifyContent="center" className={styles.container}>
|
||||||
|
<Button onClick={() => handlePageChange(-1)} disabled={firstPage}>
|
||||||
|
<Icon size="lg" className={styles.icon} rotate={90}>
|
||||||
|
<Icons.ChevronDown />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Flexbox alignItems="center" className={styles.text}>{`Page ${page} of ${maxPage}`}</Flexbox>
|
||||||
|
<Button onClick={() => handlePageChange(1)} disabled={lastPage}>
|
||||||
|
<Icon size="lg" className={styles.icon} rotate={270}>
|
||||||
|
<Icons.ChevronDown />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pager;
|
7
components/common/Pager.module.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
@ -1,37 +1,98 @@
|
|||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableColumn } from 'react-basics';
|
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
SearchField,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from 'react-basics';
|
||||||
import styles from './SettingsTable.module.css';
|
import styles from './SettingsTable.module.css';
|
||||||
|
import Pager from 'components/common/Pager';
|
||||||
|
|
||||||
|
export function SettingsTable({
|
||||||
|
columns = [],
|
||||||
|
data,
|
||||||
|
children,
|
||||||
|
cellRender,
|
||||||
|
showSearch,
|
||||||
|
showPaging,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
filterValue,
|
||||||
|
}) {
|
||||||
|
const { formatMessage, messages } = useMessages();
|
||||||
|
const [filter, setFilter] = useState(filterValue);
|
||||||
|
const { data: value, page, count, pageSize } = data;
|
||||||
|
|
||||||
|
const handleFilterChange = value => {
|
||||||
|
setFilter(value);
|
||||||
|
onFilterChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
export function SettingsTable({ columns = [], data = [], children, cellRender }) {
|
|
||||||
return (
|
return (
|
||||||
<Table columns={columns} rows={data}>
|
<>
|
||||||
<TableHeader className={styles.header}>
|
{showSearch && (
|
||||||
{(column, index) => {
|
<SearchField
|
||||||
return (
|
onChange={handleFilterChange}
|
||||||
<TableColumn key={index} className={styles.cell} style={columns[index].style}>
|
delay={1000}
|
||||||
{column.label}
|
value={filter}
|
||||||
</TableColumn>
|
placeholder="Search"
|
||||||
);
|
style={{ maxWidth: '300px', marginBottom: '10px' }}
|
||||||
}}
|
/>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody className={styles.body}>
|
{value.length === 0 && filterValue && (
|
||||||
{(row, keys, rowIndex) => {
|
<EmptyPlaceholder message={formatMessage(messages.noResultsFound)}></EmptyPlaceholder>
|
||||||
row.action = children(row, keys, rowIndex);
|
)}
|
||||||
|
{value.length > 0 && (
|
||||||
|
<Table columns={columns} rows={value}>
|
||||||
|
<TableHeader className={styles.header}>
|
||||||
|
{(column, index) => {
|
||||||
|
return (
|
||||||
|
<TableColumn key={index} className={styles.cell} style={columns[index].style}>
|
||||||
|
{column.label}
|
||||||
|
</TableColumn>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className={styles.body}>
|
||||||
|
{(row, keys, rowIndex) => {
|
||||||
|
row.action = children(row, keys, rowIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={rowIndex} data={row} keys={keys} className={styles.row}>
|
<TableRow key={rowIndex} data={row} keys={keys} className={styles.row}>
|
||||||
{(data, key, colIndex) => {
|
{(data, key, colIndex) => {
|
||||||
return (
|
return (
|
||||||
<TableCell key={colIndex} className={styles.cell} style={columns[colIndex].style}>
|
<TableCell
|
||||||
<label className={styles.label}>{columns[colIndex].label}</label>
|
key={colIndex}
|
||||||
{cellRender ? cellRender(row, data, key, colIndex) : data[key]}
|
className={styles.cell}
|
||||||
</TableCell>
|
style={columns[colIndex].style}
|
||||||
);
|
>
|
||||||
}}
|
<label className={styles.label}>{columns[colIndex].label}</label>
|
||||||
</TableRow>
|
{cellRender ? cellRender(row, data, key, colIndex) : data[key]}
|
||||||
);
|
</TableCell>
|
||||||
}}
|
);
|
||||||
</TableBody>
|
}}
|
||||||
</Table>
|
</TableRow>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</TableBody>
|
||||||
|
{showPaging && (
|
||||||
|
<Pager
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
count={count}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,12 +8,12 @@ export function WebsiteSelect({ websiteId, onSelect }) {
|
|||||||
const { data } = useQuery(['websites:me'], () => get('/me/websites'));
|
const { data } = useQuery(['websites:me'], () => get('/me/websites'));
|
||||||
|
|
||||||
const renderValue = value => {
|
const renderValue = value => {
|
||||||
return data?.find(({ id }) => id === value)?.name;
|
return data?.data?.find(({ id }) => id === value)?.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={data}
|
items={data?.data}
|
||||||
value={websiteId}
|
value={websiteId}
|
||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
|
@ -163,6 +163,7 @@ export const labels = defineMessages({
|
|||||||
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
||||||
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
||||||
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
||||||
|
retention: { id: 'label.retention', defaultMessage: 'Retention' },
|
||||||
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
|
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
|
||||||
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
|
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
|
||||||
country: { id: 'label.country', defaultMessage: 'Country' },
|
country: { id: 'label.country', defaultMessage: 'Country' },
|
||||||
|
@ -12,16 +12,17 @@ import useDashboard from 'store/dashboard';
|
|||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
import useLocale from 'hooks/useLocale';
|
import useLocale from 'hooks/useLocale';
|
||||||
|
|
||||||
export function Dashboard({ userId }) {
|
export function Dashboard() {
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const dashboard = useDashboard();
|
const dashboard = useDashboard();
|
||||||
const { showCharts, limit, editing } = dashboard;
|
const { showCharts, limit, editing } = dashboard;
|
||||||
const [max, setMax] = useState(limit);
|
const [max, setMax] = useState(limit);
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error } = useQuery(['websites'], () =>
|
const { data, isLoading, error } = useQuery(['websites'], () =>
|
||||||
get('/websites', { userId, includeTeams: 1 }),
|
get('/websites', { includeTeams: 1 }),
|
||||||
);
|
);
|
||||||
const hasData = data && data.length !== 0;
|
const hasData = data && data?.data.length !== 0;
|
||||||
|
|
||||||
const { dir } = useLocale();
|
const { dir } = useLocale();
|
||||||
|
|
||||||
function handleMore() {
|
function handleMore() {
|
||||||
@ -47,8 +48,10 @@ export function Dashboard({ userId }) {
|
|||||||
)}
|
)}
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<>
|
<>
|
||||||
{editing && <DashboardEdit websites={data} />}
|
{editing && <DashboardEdit websites={data?.data} />}
|
||||||
{!editing && <WebsiteChartList websites={data} showCharts={showCharts} limit={max} />}
|
{!editing && (
|
||||||
|
<WebsiteChartList websites={data?.data} showCharts={showCharts} limit={max} />
|
||||||
|
)}
|
||||||
{max < data.length && (
|
{max < data.length && (
|
||||||
<Flexbox justifyContent="center">
|
<Flexbox justifyContent="center">
|
||||||
<Button onClick={handleMore}>
|
<Button onClick={handleMore}>
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import FunnelReport from './funnel/FunnelReport';
|
import FunnelReport from './funnel/FunnelReport';
|
||||||
import EventDataReport from './event-data/EventDataReport';
|
import EventDataReport from './event-data/EventDataReport';
|
||||||
import InsightsReport from './insights/InsightsReport';
|
import InsightsReport from './insights/InsightsReport';
|
||||||
|
import RetentionReport from './retention/RetentionReport';
|
||||||
|
|
||||||
const reports = {
|
const reports = {
|
||||||
funnel: FunnelReport,
|
funnel: FunnelReport,
|
||||||
'event-data': EventDataReport,
|
'event-data': EventDataReport,
|
||||||
insights: InsightsReport,
|
insights: InsightsReport,
|
||||||
|
retention: RetentionReport,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReportDetails({ reportId, reportType }) {
|
export default function ReportDetails({ reportId, reportType }) {
|
||||||
|
@ -45,6 +45,12 @@ export function ReportTemplates() {
|
|||||||
url: '/reports/funnel',
|
url: '/reports/funnel',
|
||||||
icon: <Funnel />,
|
icon: <Funnel />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: formatMessage(labels.retention),
|
||||||
|
description: 'Track your websites user retention',
|
||||||
|
url: '/reports/retention',
|
||||||
|
icon: <Funnel />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -5,7 +5,14 @@ import SettingsTable from 'components/common/SettingsTable';
|
|||||||
import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm';
|
import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm';
|
||||||
import { useMessages } from 'hooks';
|
import { useMessages } from 'hooks';
|
||||||
|
|
||||||
export function ReportsTable({ data = [], onDelete = () => {} }) {
|
export function ReportsTable({
|
||||||
|
data = [],
|
||||||
|
onDelete = () => {},
|
||||||
|
filterValue,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}) {
|
||||||
const [report, setReport] = useState(null);
|
const [report, setReport] = useState(null);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
@ -22,7 +29,16 @@ export function ReportsTable({ data = [], onDelete = () => {} }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsTable columns={columns} data={data}>
|
<SettingsTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
showSearch={true}
|
||||||
|
showPaging={true}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
filterValue={filterValue}
|
||||||
|
>
|
||||||
{row => {
|
{row => {
|
||||||
const { id } = row;
|
const { id } = row;
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
import { Loading } from 'react-basics';
|
import { Loading, StatusLight } from 'react-basics';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
import useTheme from 'hooks/useTheme';
|
import useTheme from 'hooks/useTheme';
|
||||||
import BarChart from 'components/metrics/BarChart';
|
import BarChart from 'components/metrics/BarChart';
|
||||||
@ -22,14 +22,25 @@ export function FunnelChart({ className, loading }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
|
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
|
||||||
const { opacity, dataPoints } = model.tooltip;
|
const { opacity, labelColors, dataPoints } = model.tooltip;
|
||||||
|
|
||||||
if (!dataPoints?.length || !opacity) {
|
if (!dataPoints?.length || !opacity) {
|
||||||
setTooltipPopup(null);
|
setTooltipPopup(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
|
setTooltipPopup(
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<StatusLight color={labelColors?.[0]?.backgroundColor}>
|
||||||
|
{formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)}
|
||||||
|
</StatusLight>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const datasets = useMemo(() => {
|
const datasets = useMemo(() => {
|
||||||
|
41
components/pages/reports/retention/RetentionParameters.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useContext, useRef } from 'react';
|
||||||
|
import { useMessages } from 'hooks';
|
||||||
|
import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics';
|
||||||
|
import { ReportContext } from 'components/pages/reports/Report';
|
||||||
|
import BaseParameters from '../BaseParameters';
|
||||||
|
|
||||||
|
const fieldOptions = [
|
||||||
|
{ name: 'daily', type: 'string' },
|
||||||
|
{ name: 'weekly', type: 'string' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RetentionParameters() {
|
||||||
|
const { report, runReport, isRunning } = useContext(ReportContext);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const { parameters } = report || {};
|
||||||
|
const { websiteId, dateRange } = parameters || {};
|
||||||
|
const queryDisabled = !websiteId || !dateRange;
|
||||||
|
|
||||||
|
const handleSubmit = (data, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (!queryDisabled) {
|
||||||
|
runReport(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form ref={ref} values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||||
|
<BaseParameters />
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
|
||||||
|
{formatMessage(labels.runQuery)}
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RetentionParameters;
|
26
components/pages/reports/retention/RetentionReport.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import RetentionTable from './RetentionTable';
|
||||||
|
import RetentionParameters from './RetentionParameters';
|
||||||
|
import Report from '../Report';
|
||||||
|
import ReportHeader from '../ReportHeader';
|
||||||
|
import ReportMenu from '../ReportMenu';
|
||||||
|
import ReportBody from '../ReportBody';
|
||||||
|
import Funnel from 'assets/funnel.svg';
|
||||||
|
|
||||||
|
const defaultParameters = {
|
||||||
|
type: 'retention',
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RetentionReport({ reportId }) {
|
||||||
|
return (
|
||||||
|
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||||
|
<ReportHeader icon={<Funnel />} />
|
||||||
|
<ReportMenu>
|
||||||
|
<RetentionParameters />
|
||||||
|
</ReportMenu>
|
||||||
|
<ReportBody>
|
||||||
|
<RetentionTable />
|
||||||
|
</ReportBody>
|
||||||
|
</Report>
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
31
components/pages/reports/retention/RetentionTable.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { GridTable, GridColumn } from 'react-basics';
|
||||||
|
import { useMessages } from 'hooks';
|
||||||
|
import { ReportContext } from '../Report';
|
||||||
|
|
||||||
|
export function RetentionTable() {
|
||||||
|
const { report } = useContext(ReportContext);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridTable data={report?.data || []}>
|
||||||
|
<GridColumn name="date" label={'Date'}>
|
||||||
|
{row => row.date}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="day" label={'Day'}>
|
||||||
|
{row => row.day}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="visitors" label={formatMessage(labels.visitors)}>
|
||||||
|
{row => row.visitors}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="returnVisitors" label={'Return Visitors'}>
|
||||||
|
{row => row.returnVisitors}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="percentage" label={'Percentage'}>
|
||||||
|
{row => row.percentage}
|
||||||
|
</GridColumn>
|
||||||
|
</GridTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RetentionTable;
|
@ -12,6 +12,8 @@ export function TeamAddWebsiteForm({ teamId, onSave, onClose }) {
|
|||||||
const [newWebsites, setNewWebsites] = useState([]);
|
const [newWebsites, setNewWebsites] = useState([]);
|
||||||
const formRef = useRef();
|
const formRef = useRef();
|
||||||
|
|
||||||
|
const hasData = websites && websites.data.length > 0;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
mutate(
|
mutate(
|
||||||
{ websiteIds: newWebsites },
|
{ websiteIds: newWebsites },
|
||||||
@ -42,20 +44,22 @@ export function TeamAddWebsiteForm({ teamId, onSave, onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form onSubmit={handleSubmit} error={error} ref={formRef}>
|
{hasData && (
|
||||||
<FormRow label={formatMessage(labels.websites)}>
|
<Form onSubmit={handleSubmit} error={error} ref={formRef}>
|
||||||
<Dropdown items={websites} onChange={handleAddWebsite} style={{ width: 300 }}>
|
<FormRow label={formatMessage(labels.websites)}>
|
||||||
{({ id, name }) => <Item key={id}>{name}</Item>}
|
<Dropdown items={websites.data} onChange={handleAddWebsite} style={{ width: 300 }}>
|
||||||
</Dropdown>
|
{({ id, name }) => <Item key={id}>{name}</Item>}
|
||||||
</FormRow>
|
</Dropdown>
|
||||||
<WebsiteTags items={websites} websites={newWebsites} onClick={handleRemoveWebsite} />
|
</FormRow>
|
||||||
<FormButtons flex>
|
<WebsiteTags items={websites.data} websites={newWebsites} onClick={handleRemoveWebsite} />
|
||||||
<SubmitButton disabled={newWebsites && newWebsites.length === 0}>
|
<FormButtons flex>
|
||||||
{formatMessage(labels.addWebsite)}
|
<SubmitButton disabled={newWebsites && newWebsites.length === 0}>
|
||||||
</SubmitButton>
|
{formatMessage(labels.addWebsite)}
|
||||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
</SubmitButton>
|
||||||
</FormButtons>
|
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
</Form>
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,22 @@ import { Loading, useToasts } from 'react-basics';
|
|||||||
import TeamMembersTable from 'components/pages/settings/teams/TeamMembersTable';
|
import TeamMembersTable from 'components/pages/settings/teams/TeamMembersTable';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import useApiFilter from 'hooks/useApiFilter';
|
||||||
|
|
||||||
export function TeamMembers({ teamId, readOnly }) {
|
export function TeamMembers({ teamId, readOnly }) {
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const { formatMessage, messages } = useMessages();
|
const { formatMessage, messages } = useMessages();
|
||||||
const { data, isLoading, refetch } = useQuery(['teams:users', teamId], () =>
|
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||||
get(`/teams/${teamId}/users`),
|
useApiFilter();
|
||||||
|
const { get, useQuery } = useApi();
|
||||||
|
const { data, isLoading, refetch } = useQuery(
|
||||||
|
['teams:users', teamId, filter, page, pageSize],
|
||||||
|
() =>
|
||||||
|
get(`/teams/${teamId}/users`, {
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -22,7 +31,15 @@ export function TeamMembers({ teamId, readOnly }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TeamMembersTable onSave={handleSave} data={data} readOnly={readOnly} />
|
<TeamMembersTable
|
||||||
|
onSave={handleSave}
|
||||||
|
data={data}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
filterValue={filter}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,15 @@ import { ROLES } from 'lib/constants';
|
|||||||
import TeamMemberRemoveButton from './TeamMemberRemoveButton';
|
import TeamMemberRemoveButton from './TeamMemberRemoveButton';
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
import SettingsTable from 'components/common/SettingsTable';
|
||||||
|
|
||||||
export function TeamMembersTable({ data = [], onSave, readOnly }) {
|
export function TeamMembersTable({
|
||||||
|
data = [],
|
||||||
|
onSave,
|
||||||
|
readOnly,
|
||||||
|
filterValue,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
@ -16,7 +24,7 @@ export function TeamMembersTable({ data = [], onSave, readOnly }) {
|
|||||||
|
|
||||||
const cellRender = (row, data, key) => {
|
const cellRender = (row, data, key) => {
|
||||||
if (key === 'username') {
|
if (key === 'username') {
|
||||||
return row?.user?.username;
|
return row?.username;
|
||||||
}
|
}
|
||||||
if (key === 'role') {
|
if (key === 'role') {
|
||||||
return formatMessage(
|
return formatMessage(
|
||||||
@ -27,13 +35,23 @@ export function TeamMembersTable({ data = [], onSave, readOnly }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsTable data={data} columns={columns} cellRender={cellRender}>
|
<SettingsTable
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
cellRender={cellRender}
|
||||||
|
showSearch={true}
|
||||||
|
showPaging={true}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
filterValue={filterValue}
|
||||||
|
>
|
||||||
{row => {
|
{row => {
|
||||||
return (
|
return (
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
<TeamMemberRemoveButton
|
<TeamMemberRemoveButton
|
||||||
teamId={row.teamId}
|
teamId={row.teamId}
|
||||||
userId={row.userId}
|
userId={row.id}
|
||||||
disabled={user.id === row?.user?.id || row.role === ROLES.teamOwner}
|
disabled={user.id === row?.user?.id || row.role === ROLES.teamOwner}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
|
@ -13,13 +13,22 @@ import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable
|
|||||||
import TeamAddWebsiteForm from 'components/pages/settings/teams/TeamAddWebsiteForm';
|
import TeamAddWebsiteForm from 'components/pages/settings/teams/TeamAddWebsiteForm';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import useApiFilter from 'hooks/useApiFilter';
|
||||||
|
|
||||||
export function TeamWebsites({ teamId }) {
|
export function TeamWebsites({ teamId }) {
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
|
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||||
|
useApiFilter();
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () =>
|
const { data, isLoading, refetch } = useQuery(
|
||||||
get(`/teams/${teamId}/websites`),
|
['teams:websites', teamId, filter, page, pageSize],
|
||||||
|
() =>
|
||||||
|
get(`/teams/${teamId}/websites`, {
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const hasData = data && data.length !== 0;
|
const hasData = data && data.length !== 0;
|
||||||
|
|
||||||
@ -49,7 +58,17 @@ export function TeamWebsites({ teamId }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ActionForm description={formatMessage(messages.teamWebsitesInfo)}>{addButton}</ActionForm>
|
<ActionForm description={formatMessage(messages.teamWebsitesInfo)}>{addButton}</ActionForm>
|
||||||
{hasData && <TeamWebsitesTable teamId={teamId} data={data} onSave={handleSave} />}
|
{hasData && (
|
||||||
|
<TeamWebsitesTable
|
||||||
|
teamId={teamId}
|
||||||
|
data={data}
|
||||||
|
onSave={handleSave}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
filterValue={filter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,17 @@ import TeamWebsiteRemoveButton from './TeamWebsiteRemoveButton';
|
|||||||
import SettingsTable from 'components/common/SettingsTable';
|
import SettingsTable from 'components/common/SettingsTable';
|
||||||
import useConfig from 'hooks/useConfig';
|
import useConfig from 'hooks/useConfig';
|
||||||
|
|
||||||
export function TeamWebsitesTable({ data = [], onSave }) {
|
export function TeamWebsitesTable({
|
||||||
|
data = [],
|
||||||
|
onSave,
|
||||||
|
filterValue,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { openExternal } = useConfig();
|
const { openExternal } = useConfig();
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const columns = [
|
const columns = [
|
||||||
{ name: 'name', label: formatMessage(labels.name) },
|
{ name: 'name', label: formatMessage(labels.name) },
|
||||||
@ -17,11 +25,19 @@ export function TeamWebsitesTable({ data = [], onSave }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsTable columns={columns} data={data}>
|
<SettingsTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
showSearch={true}
|
||||||
|
showPaging={true}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
filterValue={filterValue}
|
||||||
|
>
|
||||||
{row => {
|
{row => {
|
||||||
const { teamId } = row;
|
const { id: teamId, teamUser } = row.teamWebsite[0].team;
|
||||||
const { id: websiteId, name, domain, userId } = row.website;
|
const { id: websiteId, name, domain, userId } = row;
|
||||||
const { teamUser } = row.team;
|
|
||||||
const owner = teamUser[0];
|
const owner = teamUser[0];
|
||||||
const canRemove = user.id === userId || user.id === owner.userId;
|
const canRemove = user.id === userId || user.id === owner.userId;
|
||||||
|
|
||||||
|
@ -1,24 +1,37 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Button, Icon, Modal, ModalTrigger, useToasts, Text, Flexbox } from 'react-basics';
|
|
||||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||||
import TeamAddForm from 'components/pages/settings/teams/TeamAddForm';
|
|
||||||
import PageHeader from 'components/layout/PageHeader';
|
|
||||||
import TeamsTable from 'components/pages/settings/teams/TeamsTable';
|
|
||||||
import Page from 'components/layout/Page';
|
|
||||||
import Icons from 'components/icons';
|
import Icons from 'components/icons';
|
||||||
import TeamJoinForm from './TeamJoinForm';
|
import Page from 'components/layout/Page';
|
||||||
|
import PageHeader from 'components/layout/PageHeader';
|
||||||
|
import TeamAddForm from 'components/pages/settings/teams/TeamAddForm';
|
||||||
|
import TeamsTable from 'components/pages/settings/teams/TeamsTable';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import useUser from 'hooks/useUser';
|
import useUser from 'hooks/useUser';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Flexbox, Icon, Modal, ModalTrigger, Text, useToasts } from 'react-basics';
|
||||||
|
import TeamJoinForm from './TeamJoinForm';
|
||||||
|
import useApiFilter from 'hooks/useApiFilter';
|
||||||
|
|
||||||
export default function TeamsList() {
|
export default function TeamsList() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
|
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||||
|
useApiFilter();
|
||||||
const [update, setUpdate] = useState(0);
|
const [update, setUpdate] = useState(0);
|
||||||
|
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error } = useQuery(['teams', update], () => get(`/teams`));
|
const { data, isLoading, error } = useQuery(['teams', update, filter, page, pageSize], () => {
|
||||||
const hasData = data && data.length !== 0;
|
return get(`/teams`, {
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = data && data?.data.length !== 0;
|
||||||
|
const isFiltered = filter;
|
||||||
|
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@ -71,15 +84,26 @@ export default function TeamsList() {
|
|||||||
return (
|
return (
|
||||||
<Page loading={isLoading} error={error}>
|
<Page loading={isLoading} error={error}>
|
||||||
<PageHeader title={formatMessage(labels.teams)}>
|
<PageHeader title={formatMessage(labels.teams)}>
|
||||||
{hasData && (
|
{(hasData || isFiltered) && (
|
||||||
<Flexbox gap={10}>
|
<Flexbox gap={10}>
|
||||||
{joinButton}
|
{joinButton}
|
||||||
{createButton}
|
{createButton}
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{hasData && <TeamsTable data={data} onDelete={handleDelete} />}
|
|
||||||
{!hasData && (
|
{(hasData || isFiltered) && (
|
||||||
|
<TeamsTable
|
||||||
|
data={data}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
filterValue={filter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasData && !isFiltered && (
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
|
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
|
||||||
<Flexbox gap={10}>
|
<Flexbox gap={10}>
|
||||||
{joinButton}
|
{joinButton}
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
|
import SettingsTable from 'components/common/SettingsTable';
|
||||||
|
import useLocale from 'hooks/useLocale';
|
||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import useUser from 'hooks/useUser';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
|
||||||
import TeamDeleteForm from './TeamDeleteForm';
|
import TeamDeleteForm from './TeamDeleteForm';
|
||||||
import TeamLeaveForm from './TeamLeaveForm';
|
import TeamLeaveForm from './TeamLeaveForm';
|
||||||
import useMessages from 'hooks/useMessages';
|
|
||||||
import useUser from 'hooks/useUser';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import SettingsTable from 'components/common/SettingsTable';
|
|
||||||
import useLocale from 'hooks/useLocale';
|
|
||||||
|
|
||||||
export function TeamsTable({ data = [], onDelete }) {
|
export function TeamsTable({
|
||||||
|
data = { data: [] },
|
||||||
|
onDelete,
|
||||||
|
filterValue,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { dir } = useLocale();
|
const { dir } = useLocale();
|
||||||
@ -27,7 +34,17 @@ export function TeamsTable({ data = [], onDelete }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsTable data={data} columns={columns} cellRender={cellRender}>
|
<SettingsTable
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
cellRender={cellRender}
|
||||||
|
showSearch={true}
|
||||||
|
showPaging={true}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
filterValue={filterValue}
|
||||||
|
>
|
||||||
{row => {
|
{row => {
|
||||||
const { id, teamUser } = row;
|
const { id, teamUser } = row;
|
||||||
const owner = teamUser.find(({ role }) => role === ROLES.teamOwner);
|
const owner = teamUser.find(({ role }) => role === ROLES.teamOwner);
|
||||||
|
@ -7,14 +7,27 @@ import UserAddButton from './UserAddButton';
|
|||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import useUser from 'hooks/useUser';
|
import useUser from 'hooks/useUser';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
|
import useApiFilter from 'hooks/useApiFilter';
|
||||||
|
|
||||||
export function UsersList() {
|
export function UsersList() {
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||||
|
useApiFilter();
|
||||||
|
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error, refetch } = useQuery(['user'], () => get(`/users`), {
|
const { data, isLoading, error, refetch } = useQuery(
|
||||||
enabled: !!user,
|
['user', filter, page, pageSize],
|
||||||
});
|
() =>
|
||||||
|
get(`/users`, {
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
enabled: !!user,
|
||||||
|
},
|
||||||
|
);
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const hasData = data && data.length !== 0;
|
const hasData = data && data.length !== 0;
|
||||||
|
|
||||||
@ -33,8 +46,17 @@ export function UsersList() {
|
|||||||
<PageHeader title={formatMessage(labels.users)}>
|
<PageHeader title={formatMessage(labels.users)}>
|
||||||
<UserAddButton onSave={handleSave} />
|
<UserAddButton onSave={handleSave} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{hasData && <UsersTable data={data} onDelete={handleDelete} />}
|
{(hasData || filter) && (
|
||||||
{!hasData && (
|
<UsersTable
|
||||||
|
data={data}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
filterValue={filter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hasData && !filter && (
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noUsers)}>
|
<EmptyPlaceholder message={formatMessage(messages.noUsers)}>
|
||||||
<UserAddButton onSave={handleSave} />
|
<UserAddButton onSave={handleSave} />
|
||||||
</EmptyPlaceholder>
|
</EmptyPlaceholder>
|
||||||
|
@ -8,7 +8,14 @@ import useMessages from 'hooks/useMessages';
|
|||||||
import SettingsTable from 'components/common/SettingsTable';
|
import SettingsTable from 'components/common/SettingsTable';
|
||||||
import useLocale from 'hooks/useLocale';
|
import useLocale from 'hooks/useLocale';
|
||||||
|
|
||||||
export function UsersTable({ data = [], onDelete }) {
|
export function UsersTable({
|
||||||
|
data = { data: [] },
|
||||||
|
onDelete,
|
||||||
|
filterValue,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { dateLocale } = useLocale();
|
const { dateLocale } = useLocale();
|
||||||
@ -36,7 +43,17 @@ export function UsersTable({ data = [], onDelete }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsTable data={data} columns={columns} cellRender={cellRender}>
|
<SettingsTable
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
cellRender={cellRender}
|
||||||
|
showSearch={true}
|
||||||
|
showPaging={true}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
filterValue={filterValue}
|
||||||
|
>
|
||||||
{(row, keys, rowIndex) => {
|
{(row, keys, rowIndex) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -8,14 +8,22 @@ import useApi from 'hooks/useApi';
|
|||||||
import useUser from 'hooks/useUser';
|
import useUser from 'hooks/useUser';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
import { ROLES } from 'lib/constants';
|
import { ROLES } from 'lib/constants';
|
||||||
|
import useApiFilter from 'hooks/useApiFilter';
|
||||||
|
|
||||||
export function WebsitesList() {
|
export function WebsitesList() {
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||||
|
useApiFilter();
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error, refetch } = useQuery(
|
const { data, isLoading, error, refetch } = useQuery(
|
||||||
['websites', user?.id],
|
['websites', user?.id, filter, page, pageSize],
|
||||||
() => get(`/users/${user?.id}/websites`),
|
() =>
|
||||||
|
get(`/users/${user?.id}/websites`, {
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
{ enabled: !!user },
|
{ enabled: !!user },
|
||||||
);
|
);
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
@ -47,7 +55,15 @@ export function WebsitesList() {
|
|||||||
return (
|
return (
|
||||||
<Page loading={isLoading} error={error}>
|
<Page loading={isLoading} error={error}>
|
||||||
<PageHeader title={formatMessage(labels.websites)}>{addButton}</PageHeader>
|
<PageHeader title={formatMessage(labels.websites)}>{addButton}</PageHeader>
|
||||||
{hasData && <WebsitesTable data={data} />}
|
{hasData && (
|
||||||
|
<WebsitesTable
|
||||||
|
data={data}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
filterValue={filter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!hasData && (
|
{!hasData && (
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
|
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
|
||||||
{addButton}
|
{addButton}
|
||||||
|
@ -4,7 +4,13 @@ import SettingsTable from 'components/common/SettingsTable';
|
|||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
import useConfig from 'hooks/useConfig';
|
import useConfig from 'hooks/useConfig';
|
||||||
|
|
||||||
export function WebsitesTable({ data = [] }) {
|
export function WebsitesTable({
|
||||||
|
data = [],
|
||||||
|
filterValue,
|
||||||
|
onFilterChange,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { openExternal } = useConfig();
|
const { openExternal } = useConfig();
|
||||||
|
|
||||||
@ -15,7 +21,16 @@ export function WebsitesTable({ data = [] }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsTable columns={columns} data={data}>
|
<SettingsTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
showSearch={true}
|
||||||
|
showPaging={true}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
filterValue={filterValue}
|
||||||
|
>
|
||||||
{row => {
|
{row => {
|
||||||
const { id } = row;
|
const { id } = row;
|
||||||
|
|
||||||
|
@ -7,7 +7,16 @@ import WebsiteHeader from './WebsiteHeader';
|
|||||||
|
|
||||||
export function WebsiteReportsPage({ websiteId }) {
|
export function WebsiteReportsPage({ websiteId }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { reports, error, isLoading, deleteReport } = useReports(websiteId);
|
const {
|
||||||
|
reports,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
deleteReport,
|
||||||
|
filter,
|
||||||
|
handleFilterChange,
|
||||||
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
|
} = useReports(websiteId);
|
||||||
|
|
||||||
const handleDelete = async id => {
|
const handleDelete = async id => {
|
||||||
await deleteReport(id);
|
await deleteReport(id);
|
||||||
@ -26,7 +35,14 @@ export function WebsiteReportsPage({ websiteId }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
<ReportsTable data={reports} onDelete={handleDelete} />
|
<ReportsTable
|
||||||
|
data={reports}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
filterValue={filter}
|
||||||
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
28
hooks/useApiFilter.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function useApiFilter() {
|
||||||
|
const [filter, setFilter] = useState();
|
||||||
|
const [filterType, setFilterType] = useState('All');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const handleFilterChange = value => setFilter(value);
|
||||||
|
const handlePageChange = value => setPage(value);
|
||||||
|
const handlePageSizeChange = value => setPageSize(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
filterType,
|
||||||
|
setFilterType,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
setPageSize,
|
||||||
|
handleFilterChange,
|
||||||
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useApiFilter;
|
@ -1,12 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import useApi from './useApi';
|
import useApi from './useApi';
|
||||||
|
import useApiFilter from 'hooks/useApiFilter';
|
||||||
|
|
||||||
export function useReports(websiteId) {
|
export function useReports(websiteId) {
|
||||||
const [modified, setModified] = useState(Date.now());
|
const [modified, setModified] = useState(Date.now());
|
||||||
const { get, useQuery, del, useMutation } = useApi();
|
const { get, useQuery, del, useMutation } = useApi();
|
||||||
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
|
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
|
||||||
const { data, error, isLoading } = useQuery(['reports:website', { websiteId, modified }], () =>
|
const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } =
|
||||||
get(`/reports`, { websiteId }),
|
useApiFilter();
|
||||||
|
const { data, error, isLoading } = useQuery(
|
||||||
|
['reports:website', { websiteId, modified, filter, page, pageSize }],
|
||||||
|
() => get(`/reports`, { websiteId, filter, page, pageSize }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteReport = id => {
|
const deleteReport = id => {
|
||||||
@ -17,7 +21,18 @@ export function useReports(websiteId) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return { reports: data, error, isLoading, deleteReport };
|
return {
|
||||||
|
reports: data,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
deleteReport,
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
handleFilterChange,
|
||||||
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useReports;
|
export default useReports;
|
||||||
|
@ -30,6 +30,22 @@ export const FILTER_RANGE = 'filter-range';
|
|||||||
export const FILTER_REFERRERS = 'filter-referrers';
|
export const FILTER_REFERRERS = 'filter-referrers';
|
||||||
export const FILTER_PAGES = 'filter-pages';
|
export const FILTER_PAGES = 'filter-pages';
|
||||||
|
|
||||||
|
export const USER_FILTER_TYPES = {
|
||||||
|
all: 'All',
|
||||||
|
username: 'Username',
|
||||||
|
} as const;
|
||||||
|
export const WEBSITE_FILTER_TYPES = { all: 'All', name: 'Name', domain: 'Domain' } as const;
|
||||||
|
export const TEAM_FILTER_TYPES = { all: 'All', name: 'Name', 'user:username': 'Owner' } as const;
|
||||||
|
export const REPORT_FILTER_TYPES = {
|
||||||
|
all: 'All',
|
||||||
|
name: 'Name',
|
||||||
|
description: 'Description',
|
||||||
|
type: 'Type',
|
||||||
|
'user:username': 'Username',
|
||||||
|
'website:name': 'Website Name',
|
||||||
|
'website:domain': 'Website Domain',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event'];
|
export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event'];
|
||||||
|
|
||||||
export const SESSION_COLUMNS = [
|
export const SESSION_COLUMNS = [
|
||||||
|
@ -4,7 +4,7 @@ import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
|
|||||||
import { FILTER_COLUMNS, SESSION_COLUMNS, OPERATORS } from './constants';
|
import { FILTER_COLUMNS, SESSION_COLUMNS, OPERATORS } from './constants';
|
||||||
import { loadWebsite } from './load';
|
import { loadWebsite } from './load';
|
||||||
import { maxDate } from './date';
|
import { maxDate } from './date';
|
||||||
import { QueryFilters, QueryOptions } from './types';
|
import { QueryFilters, QueryOptions, SearchFilter } from './types';
|
||||||
|
|
||||||
const MYSQL_DATE_FORMATS = {
|
const MYSQL_DATE_FORMATS = {
|
||||||
minute: '%Y-%m-%d %H:%i:00',
|
minute: '%Y-%m-%d %H:%i:00',
|
||||||
@ -146,6 +146,37 @@ async function rawQuery(sql: string, data: object): Promise<any> {
|
|||||||
return prisma.rawQuery(query, params);
|
return prisma.rawQuery(query, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPageFilters(filters: SearchFilter<any>): [
|
||||||
|
{
|
||||||
|
orderBy: {
|
||||||
|
[x: string]: string;
|
||||||
|
}[];
|
||||||
|
take: number;
|
||||||
|
skip: number;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pageSize: number;
|
||||||
|
page: number;
|
||||||
|
orderBy: string;
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
const { pageSize = 10, page = 1, orderBy } = filters;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...(pageSize > 0 && { take: pageSize, skip: pageSize * (page - 1) }),
|
||||||
|
...(orderBy && {
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
[orderBy]: 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ pageSize, page: +page, orderBy },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...prisma,
|
...prisma,
|
||||||
getAddMinutesQuery,
|
getAddMinutesQuery,
|
||||||
@ -153,5 +184,6 @@ export default {
|
|||||||
getTimestampIntervalQuery,
|
getTimestampIntervalQuery,
|
||||||
getFilterQuery,
|
getFilterQuery,
|
||||||
parseFilters,
|
parseFilters,
|
||||||
|
getPageFilters,
|
||||||
rawQuery,
|
rawQuery,
|
||||||
};
|
};
|
||||||
|
55
lib/types.ts
@ -1,17 +1,62 @@
|
|||||||
import { NextApiRequest } from 'next';
|
import { NextApiRequest } from 'next';
|
||||||
import { COLLECTION_TYPE, DATA_TYPE, EVENT_TYPE, KAFKA_TOPIC, ROLES } from './constants';
|
import {
|
||||||
|
COLLECTION_TYPE,
|
||||||
|
DATA_TYPE,
|
||||||
|
EVENT_TYPE,
|
||||||
|
KAFKA_TOPIC,
|
||||||
|
REPORT_FILTER_TYPES,
|
||||||
|
ROLES,
|
||||||
|
TEAM_FILTER_TYPES,
|
||||||
|
USER_FILTER_TYPES,
|
||||||
|
WEBSITE_FILTER_TYPES,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
type ObjectValues<T> = T[keyof T];
|
type ObjectValues<T> = T[keyof T];
|
||||||
|
|
||||||
export type CollectionType = ObjectValues<typeof COLLECTION_TYPE>;
|
export type CollectionType = ObjectValues<typeof COLLECTION_TYPE>;
|
||||||
|
|
||||||
export type Role = ObjectValues<typeof ROLES>;
|
export type Role = ObjectValues<typeof ROLES>;
|
||||||
|
|
||||||
export type EventType = ObjectValues<typeof EVENT_TYPE>;
|
export type EventType = ObjectValues<typeof EVENT_TYPE>;
|
||||||
|
|
||||||
export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
|
export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
|
||||||
|
|
||||||
export type KafkaTopic = ObjectValues<typeof KAFKA_TOPIC>;
|
export type KafkaTopic = ObjectValues<typeof KAFKA_TOPIC>;
|
||||||
|
export type ReportSearchFilterType = ObjectValues<typeof REPORT_FILTER_TYPES>;
|
||||||
|
export type UserSearchFilterType = ObjectValues<typeof USER_FILTER_TYPES>;
|
||||||
|
export type WebsiteSearchFilterType = ObjectValues<typeof WEBSITE_FILTER_TYPES>;
|
||||||
|
export type TeamSearchFilterType = ObjectValues<typeof TEAM_FILTER_TYPES>;
|
||||||
|
|
||||||
|
export interface WebsiteSearchFilter extends SearchFilter<WebsiteSearchFilterType> {
|
||||||
|
userId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
includeTeams?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSearchFilter extends SearchFilter<UserSearchFilterType> {
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSearchFilter extends SearchFilter<TeamSearchFilterType> {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSearchFilter extends SearchFilter<ReportSearchFilterType> {
|
||||||
|
userId?: string;
|
||||||
|
websiteId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFilter<T> {
|
||||||
|
filter?: string;
|
||||||
|
filterType?: T;
|
||||||
|
pageSize?: number;
|
||||||
|
page?: number;
|
||||||
|
orderBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterResult<T> {
|
||||||
|
data: T;
|
||||||
|
count: number;
|
||||||
|
pageSize: number;
|
||||||
|
page: number;
|
||||||
|
orderBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DynamicData {
|
export interface DynamicData {
|
||||||
[key: string]: number | string | DynamicData | number[] | string[] | DynamicData[];
|
[key: string]: number | string | DynamicData | number[] | string[] | DynamicData[];
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
import { useCors } from 'lib/middleware';
|
import { useCors } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed } from 'next-basics';
|
import { methodNotAllowed } from 'next-basics';
|
||||||
import userTeams from 'pages/api/users/[id]/teams';
|
import userTeams from 'pages/api/users/[id]/teams';
|
||||||
|
|
||||||
export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => {
|
export interface MyTeamsRequestQuery extends SearchFilter<TeamSearchFilterType> {}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<MyTeamsRequestQuery, any>,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
import { useAuth, useCors } from 'lib/middleware';
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, WebsiteSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed } from 'next-basics';
|
import { methodNotAllowed } from 'next-basics';
|
||||||
|
|
||||||
import userWebsites from 'pages/api/users/[id]/websites';
|
import userWebsites from 'pages/api/users/[id]/websites';
|
||||||
|
|
||||||
export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => {
|
export interface MyWebsitesRequestQuery extends SearchFilter<WebsiteSearchFilterType> {}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<MyWebsitesRequestQuery, any>,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { useAuth, useCors } from 'lib/middleware';
|
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
|
||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
|
||||||
import { createReport, getWebsiteReports } from 'queries';
|
|
||||||
import { canViewWebsite } from 'lib/auth';
|
import { canViewWebsite } from 'lib/auth';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody, ReportSearchFilterType, SearchFilter } from 'lib/types';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
|
import { createReport, getReportsByWebsiteId } from 'queries';
|
||||||
|
|
||||||
|
export interface ReportsRequestQuery extends SearchFilter<ReportSearchFilterType> {}
|
||||||
|
|
||||||
export interface ReportRequestBody {
|
export interface ReportRequestBody {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
@ -35,7 +37,13 @@ export default async (
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getWebsiteReports(websiteId);
|
const { page, filter, pageSize } = req.query;
|
||||||
|
|
||||||
|
const data = await getReportsByWebsiteId(websiteId, {
|
||||||
|
page,
|
||||||
|
filter,
|
||||||
|
pageSize: +pageSize || null,
|
||||||
|
});
|
||||||
|
|
||||||
return ok(res, data);
|
return ok(res, data);
|
||||||
}
|
}
|
||||||
|
44
pages/api/reports/retention.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 { getRetention } from 'queries';
|
||||||
|
|
||||||
|
export interface RetentionRequestBody {
|
||||||
|
websiteId: string;
|
||||||
|
dateRange: { window; startDate: string; endDate: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionResponse {
|
||||||
|
startAt: number;
|
||||||
|
endAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<any, RetentionRequestBody>,
|
||||||
|
res: NextApiResponse<RetentionResponse>,
|
||||||
|
) => {
|
||||||
|
await useCors(req, res);
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(req.auth, websiteId))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getRetention(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
@ -1,11 +1,11 @@
|
|||||||
import { canUpdateTeam, canViewTeam } from 'lib/auth';
|
import { canUpdateTeam, canViewTeam } from 'lib/auth';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createTeamUser, getTeamUsers, getUserByUsername } from 'queries';
|
import { createTeamUser, getUserByUsername, getUsersByTeamId } from 'queries';
|
||||||
|
|
||||||
export interface TeamUserRequestQuery {
|
export interface TeamUserRequestQuery extends SearchFilter<TeamSearchFilterType> {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,7 +27,13 @@ export default async (
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await getTeamUsers(teamId);
|
const { page, filter, pageSize } = req.query;
|
||||||
|
|
||||||
|
const users = await getUsersByTeamId(teamId, {
|
||||||
|
page,
|
||||||
|
filter,
|
||||||
|
pageSize: +pageSize || null,
|
||||||
|
});
|
||||||
|
|
||||||
return ok(res, users);
|
return ok(res, users);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { canViewTeam } from 'lib/auth';
|
import { canViewTeam } from 'lib/auth';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createTeamWebsites, getTeamWebsites } from 'queries/admin/teamWebsite';
|
import { getWebsites, getWebsitesByTeamId } from 'queries';
|
||||||
|
import { createTeamWebsites } from 'queries/admin/teamWebsite';
|
||||||
|
|
||||||
export interface TeamWebsiteRequestQuery {
|
export interface TeamWebsiteRequestQuery extends SearchFilter<WebsiteSearchFilterType> {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,7 +27,13 @@ export default async (
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const websites = await getTeamWebsites(teamId);
|
const { page, filter, pageSize } = req.query;
|
||||||
|
|
||||||
|
const websites = await getWebsitesByTeamId(teamId, {
|
||||||
|
page,
|
||||||
|
filter,
|
||||||
|
pageSize: +pageSize || null,
|
||||||
|
});
|
||||||
|
|
||||||
return ok(res, websites);
|
return ok(res, websites);
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
import { Team } from '@prisma/client';
|
import { Team } from '@prisma/client';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
|
||||||
import { canCreateTeam } from 'lib/auth';
|
import { canCreateTeam } from 'lib/auth';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { getRandomChars, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { getRandomChars, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createTeam, getUserTeams } from 'queries';
|
import { createTeam, getTeamsByUserId } from 'queries';
|
||||||
|
|
||||||
export interface TeamsRequestBody {
|
export interface TeamsRequestQuery extends SearchFilter<TeamSearchFilterType> {}
|
||||||
|
export interface TeamsRequestBody extends SearchFilter<TeamSearchFilterType> {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<any, TeamsRequestBody>,
|
req: NextApiRequestQueryBody<TeamsRequestQuery, TeamsRequestBody>,
|
||||||
res: NextApiResponse<Team[] | Team>,
|
res: NextApiResponse<Team[] | Team>,
|
||||||
) => {
|
) => {
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
@ -22,9 +23,11 @@ export default async (
|
|||||||
} = req.auth;
|
} = req.auth;
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const teams = await getUserTeams(userId);
|
const { page, filter, pageSize } = req.query;
|
||||||
|
|
||||||
return ok(res, teams);
|
const results = await getTeamsByUserId(userId, { page, filter, pageSize: +pageSize || null });
|
||||||
|
|
||||||
|
return ok(res, results);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
|
@ -1,17 +1,21 @@
|
|||||||
import { useAuth, useCors } from 'lib/middleware';
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { getUserTeams } from 'queries';
|
import { getTeamsByUserId } from 'queries';
|
||||||
|
|
||||||
export interface UserWebsitesRequestBody {
|
export interface UserTeamsRequestQuery extends SearchFilter<TeamSearchFilterType> {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTeamsRequestBody {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
shareId: string;
|
shareId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<any, UserWebsitesRequestBody>,
|
req: NextApiRequestQueryBody<any, UserTeamsRequestBody>,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) => {
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
@ -25,7 +29,13 @@ export default async (
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const teams = await getUserTeams(userId);
|
const { page, filter, pageSize } = req.query;
|
||||||
|
|
||||||
|
const teams = await getTeamsByUserId(userId, {
|
||||||
|
page,
|
||||||
|
filter,
|
||||||
|
pageSize: +pageSize || null,
|
||||||
|
});
|
||||||
|
|
||||||
return ok(res, teams);
|
return ok(res, teams);
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { useAuth, useCors } from 'lib/middleware';
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { getUserWebsites } from 'queries';
|
import { getWebsitesByUserId } from 'queries';
|
||||||
|
|
||||||
|
export interface UserWebsitesRequestQuery extends SearchFilter<WebsiteSearchFilterType> {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
export interface UserWebsitesRequestBody {
|
export interface UserWebsitesRequestBody {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
@ -16,17 +19,21 @@ export default async (
|
|||||||
) => {
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
const { user } = req.auth;
|
const { user } = req.auth;
|
||||||
const { id: userId } = req.query;
|
const { id: userId, page, filter, pageSize, includeTeams } = req.query;
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
if (!user.isAdmin && user.id !== userId) {
|
if (!user.isAdmin && user.id !== userId) {
|
||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { includeTeams } = req.query;
|
const websites = await getWebsitesByUserId(userId, {
|
||||||
|
page,
|
||||||
const websites = await getUserWebsites(userId, { includeTeams });
|
filter,
|
||||||
|
pageSize: +pageSize || null,
|
||||||
|
includeTeams,
|
||||||
|
});
|
||||||
|
|
||||||
return ok(res, websites);
|
return ok(res, websites);
|
||||||
}
|
}
|
||||||
|
@ -2,11 +2,12 @@ import { canCreateUser, canViewUsers } from 'lib/auth';
|
|||||||
import { ROLES } from 'lib/constants';
|
import { ROLES } from 'lib/constants';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody, Role, User } from 'lib/types';
|
import { NextApiRequestQueryBody, Role, SearchFilter, User, UserSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createUser, getUserByUsername, getUsers } from 'queries';
|
import { createUser, getUserByUsername, getUsers } from 'queries';
|
||||||
|
|
||||||
|
export interface UsersRequestQuery extends SearchFilter<UserSearchFilterType> {}
|
||||||
export interface UsersRequestBody {
|
export interface UsersRequestBody {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -15,7 +16,7 @@ export interface UsersRequestBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<any, UsersRequestBody>,
|
req: NextApiRequestQueryBody<UsersRequestQuery, UsersRequestBody>,
|
||||||
res: NextApiResponse<User[] | User>,
|
res: NextApiResponse<User[] | User>,
|
||||||
) => {
|
) => {
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
@ -25,7 +26,9 @@ export default async (
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await getUsers();
|
const { page, filter, pageSize } = req.query;
|
||||||
|
|
||||||
|
const users = await getUsers({ page, filter, pageSize: +pageSize || null });
|
||||||
|
|
||||||
return ok(res, users);
|
return ok(res, users);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { canCreateWebsite } from 'lib/auth';
|
import { canCreateWebsite } from 'lib/auth';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
import { useAuth, useCors } from 'lib/middleware';
|
import { useAuth, useCors } from 'lib/middleware';
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types';
|
||||||
import { NextApiResponse } from 'next';
|
import { NextApiResponse } from 'next';
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
import { createWebsite } from 'queries';
|
import { createWebsite } from 'queries';
|
||||||
import userWebsites from 'pages/api/users/[id]/websites';
|
import userWebsites from 'pages/api/users/[id]/websites';
|
||||||
|
|
||||||
|
export interface WebsitesRequestQuery extends SearchFilter<WebsiteSearchFilterType> {}
|
||||||
|
|
||||||
export interface WebsitesRequestBody {
|
export interface WebsitesRequestBody {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
@ -14,7 +16,7 @@ export interface WebsitesRequestBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<any, WebsitesRequestBody>,
|
req: NextApiRequestQueryBody<WebsitesRequestQuery, WebsitesRequestBody>,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) => {
|
) => {
|
||||||
await useCors(req, res);
|
await useCors(req, res);
|
||||||
@ -26,6 +28,7 @@ export default async (
|
|||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
req.query.id = userId;
|
req.query.id = userId;
|
||||||
|
req.query.pageSize = 100;
|
||||||
|
|
||||||
return userWebsites(req, res);
|
return userWebsites(req, res);
|
||||||
}
|
}
|
||||||
|
13
pages/reports/retention.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import AppLayout from 'components/layout/AppLayout';
|
||||||
|
import RetentionReport from 'components/pages/reports/retention/RetentionReport';
|
||||||
|
import useMessages from 'hooks/useMessages';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout title={`${formatMessage(labels.retention)} - ${formatMessage(labels.reports)}`}>
|
||||||
|
<RetentionReport />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 580 B |
Before Width: | Height: | Size: 888 B After Width: | Height: | Size: 851 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 420 B |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 657 B |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.5 KiB |
@ -1,75 +1 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" width="1141.333" height="1141.333" preserveAspectRatio="xMidYMid meet" version="1.0" viewBox="0 0 856 856"><metadata>Created by potrace 1.11, written by Peter Selinger 2001-2013</metadata><g fill="#000" stroke="none"><path d="M4027 8163 c-2 -2 -28 -5 -58 -7 -50 -4 -94 -9 -179 -22 -19 -2 -48 -6 -65 -9 -47 -6 -236 -44 -280 -55 -22 -6 -49 -12 -60 -15 -34 -6 -58 -13 -130 -36 -38 -13 -72 -23 -75 -24 -29 -6 -194 -66 -264 -96 -49 -22 -95 -39 -102 -39 -7 0 -19 -7 -28 -15 -8 -9 -18 -15 -21 -14 -7 1 -197 -92 -205 -101 -3 -3 -21 -13 -40 -24 -79 -42 -123 -69 -226 -137 -94 -62 -246 -173 -280 -204 -6 -5 -29 -25 -52 -43 -136 -111 -329 -305 -457 -462 -21 -25 -41 -47 -44 -50 -4 -3 -22 -26 -39 -52 -18 -25 -38 -52 -45 -60 -34 -35 -207 -308 -259 -408 -13 -25 -25 -47 -28 -50 -11 -11 -121 -250 -159 -346 -42 -105 -114 -321 -126 -374 l-7 -30 -263 0 c-245 0 -268 -2 -321 -21 -94 -35 -171 -122 -191 -216 -9 -39 -8 -852 0 -938 9 -87 16 -150 23 -195 3 -19 6 -48 8 -65 3 -29 14 -97 22 -140 3 -11 7 -36 10 -55 3 -19 9 -51 14 -70 5 -19 11 -46 14 -60 29 -138 104 -401 145 -505 5 -11 23 -58 42 -105 18 -47 42 -105 52 -130 11 -25 21 -49 22 -55 3 -10 109 -224 164 -330 18 -33 50 -89 71 -124 22 -34 40 -64 40 -66 0 -8 104 -161 114 -167 6 -4 7 -8 3 -8 -4 0 4 -12 18 -27 14 -15 25 -32 25 -36 0 -5 6 -14 13 -21 6 -7 21 -25 32 -41 11 -15 34 -44 50 -64 17 -21 41 -52 55 -70 13 -18 33 -43 45 -56 11 -13 42 -49 70 -81 100 -118 359 -369 483 -469 34 -27 62 -53 62 -57 0 -5 6 -8 13 -8 7 0 19 -9 27 -20 8 -11 19 -20 26 -20 6 0 19 -9 29 -20 10 -11 22 -20 27 -20 5 0 23 -13 41 -30 18 -16 37 -30 44 -30 6 0 13 -4 15 -8 3 -8 186 -132 194 -132 2 0 27 -15 56 -34 132 -83 377 -207 558 -280 36 -15 74 -31 85 -36 62 -26 220 -81 320 -109 79 -23 191 -53 214 -57 14 -3 28 -7 31 -9 4 -2 20 -7 36 -9 16 -3 40 -8 54 -11 14 -3 36 -8 50 -11 14 -2 36 -7 50 -10 13 -3 40 -8 60 -10 19 -2 46 -7 60 -10 54 -10 171 -25 320 -40 90 -9 613 -12 636 -4 11 5 28 4 37 -1 9 -6 17 -6 17 -1 0 4 10 8 23 9 29 0 154 12 192 18 17 3 46 7 65 9 70 10 131 20 183 32 16 3 38 7 50 9 45 7 165 36 252 60 50 14 100 28 112 30 12 3 34 10 48 15 14 5 25 7 25 4 0 -4 6 -2 13 3 6 6 30 16 52 22 22 7 47 15 55 18 8 4 17 7 20 7 10 2 179 68 240 94 96 40 342 159 395 191 17 10 53 30 80 46 28 15 81 47 118 71 37 24 72 44 76 44 5 0 11 3 13 8 2 4 30 25 63 47 33 22 62 42 65 45 3 3 50 38 105 79 55 40 105 79 110 85 6 6 24 22 40 34 85 65 465 430 465 447 0 3 8 13 18 23 9 10 35 40 57 66 22 27 47 56 55 65 8 9 42 52 74 96 32 44 71 96 85 115 140 183 358 576 461 830 12 30 28 69 36 85 24 56 123 355 117 355 -3 0 -1 6 5 13 6 6 14 30 18 52 10 48 9 46 17 65 5 13 37 155 52 230 9 42 35 195 40 231 34 235 40 357 40 804 l0 420 -24 44 c-46 87 -143 157 -231 166 -19 2 -144 4 -276 4 l-242 1 -36 118 c-21 64 -46 139 -56 166 -11 27 -20 52 -20 57 0 5 -11 33 -25 63 -14 30 -25 58 -25 61 0 18 -152 329 -162 333 -5 2 -8 10 -8 18 0 8 -4 14 -10 14 -5 0 -9 3 -8 8 3 9 -40 82 -128 217 -63 97 -98 145 -187 259 -133 171 -380 420 -559 564 -71 56 -132 102 -138 102 -5 0 -10 3 -10 8 0 4 -25 23 -55 42 -30 19 -55 38 -55 43 0 4 -6 7 -13 7 -7 0 -22 8 -33 18 -11 9 -37 26 -59 37 -21 11 -44 25 -50 30 -41 37 -413 220 -540 266 -27 9 -61 22 -75 27 -14 5 -28 10 -32 11 -4 1 -28 10 -53 21 -25 11 -46 19 -48 18 -2 -1 -109 29 -137 40 -13 4 -32 9 -65 16 -5 1 -16 5 -22 9 -7 5 -13 6 -13 3 0 -2 -15 0 -32 5 -18 5 -44 11 -58 14 -14 3 -36 7 -50 10 -14 3 -50 9 -80 15 -30 6 -64 12 -75 14 -11 2 -45 6 -75 10 -30 4 -71 9 -90 12 -19 3 -53 6 -75 7 -22 1 -44 5 -50 8 -11 7 -542 9 -548 2z m57 -404 c7 10 436 8 511 -3 22 -3 60 -8 85 -11 25 -2 56 -6 70 -9 14 -2 43 -7 65 -10 38 -5 58 -9 115 -21 14 -3 34 -7 45 -9 11 -2 58 -14 105 -26 47 -12 92 -23 100 -25 35 -7 279 -94 308 -109 17 -9 34 -16 37 -16 3 1 20 -6 38 -14 17 -8 68 -31 112 -51 44 -20 82 -35 84 -35 2 1 7 -3 10 -8 3 -5 43 -28 88 -51 45 -23 87 -48 93 -56 7 -8 17 -15 22 -15 12 0 192 -121 196 -132 2 -4 8 -8 13 -8 10 0 119 -86 220 -172 102 -87 256 -244 349 -357 25 -30 53 -63 63 -73 9 -10 17 -22 17 -28 0 -5 3 -10 8 -10 4 0 25 -27 46 -60 22 -33 43 -60 48 -60 4 0 8 -5 8 -11 0 -6 11 -25 25 -43 14 -18 25 -38 25 -44 0 -7 4 -12 8 -12 5 0 16 -15 25 -32 9 -18 30 -55 47 -83 46 -77 161 -305 154 -305 -4 0 -2 -6 4 -12 6 -7 23 -47 40 -88 16 -41 33 -84 37 -95 5 -11 9 -22 10 -25 0 -3 11 -36 24 -73 13 -38 21 -70 19 -73 -3 -2 -1386 -3 -3075 -2 l-3071 3 38 110 c47 137 117 301 182 425 62 118 167 295 191 320 9 11 17 22 17 25 0 7 39 63 58 83 6 7 26 35 44 60 18 26 37 52 43 57 6 6 34 37 61 70 48 59 271 286 329 335 17 14 53 43 80 65 28 22 52 42 55 45 3 3 21 17 40 30 19 14 40 28 45 32 40 32 105 78 109 78 3 0 28 16 55 35 26 19 53 35 58 35 5 0 18 8 29 18 17 15 53 35 216 119 118 60 412 176 422 166 3 -4 6 -2 6 4 0 6 12 13 28 16 15 3 52 12 82 21 30 9 63 19 73 21 10 2 27 7 37 10 10 3 29 8 42 10 13 3 48 10 78 16 30 7 61 12 68 12 6 0 12 4 12 9 0 5 5 6 10 3 6 -4 34 -2 63 4 51 11 71 13 197 26 36 4 67 9 69 11 2 2 10 -1 17 -7 8 -6 14 -7 18 0z" transform="translate(0.000000,856.000000) scale(0.100000,-0.100000)"/></g></svg>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
|
||||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
||||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="856.000000pt" height="856.000000pt" viewBox="0 0 856.000000 856.000000"
|
|
||||||
preserveAspectRatio="xMidYMid meet">
|
|
||||||
<metadata>
|
|
||||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
|
||||||
</metadata>
|
|
||||||
<g transform="translate(0.000000,856.000000) scale(0.100000,-0.100000)"
|
|
||||||
fill="#000000" stroke="none">
|
|
||||||
<path d="M4027 8163 c-2 -2 -28 -5 -58 -7 -50 -4 -94 -9 -179 -22 -19 -2 -48
|
|
||||||
-6 -65 -9 -47 -6 -236 -44 -280 -55 -22 -6 -49 -12 -60 -15 -34 -6 -58 -13
|
|
||||||
-130 -36 -38 -13 -72 -23 -75 -24 -29 -6 -194 -66 -264 -96 -49 -22 -95 -39
|
|
||||||
-102 -39 -7 0 -19 -7 -28 -15 -8 -9 -18 -15 -21 -14 -7 1 -197 -92 -205 -101
|
|
||||||
-3 -3 -21 -13 -40 -24 -79 -42 -123 -69 -226 -137 -94 -62 -246 -173 -280
|
|
||||||
-204 -6 -5 -29 -25 -52 -43 -136 -111 -329 -305 -457 -462 -21 -25 -41 -47
|
|
||||||
-44 -50 -4 -3 -22 -26 -39 -52 -18 -25 -38 -52 -45 -60 -34 -35 -207 -308
|
|
||||||
-259 -408 -13 -25 -25 -47 -28 -50 -11 -11 -121 -250 -159 -346 -42 -105 -114
|
|
||||||
-321 -126 -374 l-7 -30 -263 0 c-245 0 -268 -2 -321 -21 -94 -35 -171 -122
|
|
||||||
-191 -216 -9 -39 -8 -852 0 -938 9 -87 16 -150 23 -195 3 -19 6 -48 8 -65 3
|
|
||||||
-29 14 -97 22 -140 3 -11 7 -36 10 -55 3 -19 9 -51 14 -70 5 -19 11 -46 14
|
|
||||||
-60 29 -138 104 -401 145 -505 5 -11 23 -58 42 -105 18 -47 42 -105 52 -130
|
|
||||||
11 -25 21 -49 22 -55 3 -10 109 -224 164 -330 18 -33 50 -89 71 -124 22 -34
|
|
||||||
40 -64 40 -66 0 -8 104 -161 114 -167 6 -4 7 -8 3 -8 -4 0 4 -12 18 -27 14
|
|
||||||
-15 25 -32 25 -36 0 -5 6 -14 13 -21 6 -7 21 -25 32 -41 11 -15 34 -44 50 -64
|
|
||||||
17 -21 41 -52 55 -70 13 -18 33 -43 45 -56 11 -13 42 -49 70 -81 100 -118 359
|
|
||||||
-369 483 -469 34 -27 62 -53 62 -57 0 -5 6 -8 13 -8 7 0 19 -9 27 -20 8 -11
|
|
||||||
19 -20 26 -20 6 0 19 -9 29 -20 10 -11 22 -20 27 -20 5 0 23 -13 41 -30 18
|
|
||||||
-16 37 -30 44 -30 6 0 13 -4 15 -8 3 -8 186 -132 194 -132 2 0 27 -15 56 -34
|
|
||||||
132 -83 377 -207 558 -280 36 -15 74 -31 85 -36 62 -26 220 -81 320 -109 79
|
|
||||||
-23 191 -53 214 -57 14 -3 28 -7 31 -9 4 -2 20 -7 36 -9 16 -3 40 -8 54 -11
|
|
||||||
14 -3 36 -8 50 -11 14 -2 36 -7 50 -10 13 -3 40 -8 60 -10 19 -2 46 -7 60 -10
|
|
||||||
54 -10 171 -25 320 -40 90 -9 613 -12 636 -4 11 5 28 4 37 -1 9 -6 17 -6 17
|
|
||||||
-1 0 4 10 8 23 9 29 0 154 12 192 18 17 3 46 7 65 9 70 10 131 20 183 32 16 3
|
|
||||||
38 7 50 9 45 7 165 36 252 60 50 14 100 28 112 30 12 3 34 10 48 15 14 5 25 7
|
|
||||||
25 4 0 -4 6 -2 13 3 6 6 30 16 52 22 22 7 47 15 55 18 8 4 17 7 20 7 10 2 179
|
|
||||||
68 240 94 96 40 342 159 395 191 17 10 53 30 80 46 28 15 81 47 118 71 37 24
|
|
||||||
72 44 76 44 5 0 11 3 13 8 2 4 30 25 63 47 33 22 62 42 65 45 3 3 50 38 105
|
|
||||||
79 55 40 105 79 110 85 6 6 24 22 40 34 85 65 465 430 465 447 0 3 8 13 18 23
|
|
||||||
9 10 35 40 57 66 22 27 47 56 55 65 8 9 42 52 74 96 32 44 71 96 85 115 140
|
|
||||||
183 358 576 461 830 12 30 28 69 36 85 24 56 123 355 117 355 -3 0 -1 6 5 13
|
|
||||||
6 6 14 30 18 52 10 48 9 46 17 65 5 13 37 155 52 230 9 42 35 195 40 231 34
|
|
||||||
235 40 357 40 804 l0 420 -24 44 c-46 87 -143 157 -231 166 -19 2 -144 4 -276
|
|
||||||
4 l-242 1 -36 118 c-21 64 -46 139 -56 166 -11 27 -20 52 -20 57 0 5 -11 33
|
|
||||||
-25 63 -14 30 -25 58 -25 61 0 18 -152 329 -162 333 -5 2 -8 10 -8 18 0 8 -4
|
|
||||||
14 -10 14 -5 0 -9 3 -8 8 3 9 -40 82 -128 217 -63 97 -98 145 -187 259 -133
|
|
||||||
171 -380 420 -559 564 -71 56 -132 102 -138 102 -5 0 -10 3 -10 8 0 4 -25 23
|
|
||||||
-55 42 -30 19 -55 38 -55 43 0 4 -6 7 -13 7 -7 0 -22 8 -33 18 -11 9 -37 26
|
|
||||||
-59 37 -21 11 -44 25 -50 30 -41 37 -413 220 -540 266 -27 9 -61 22 -75 27
|
|
||||||
-14 5 -28 10 -32 11 -4 1 -28 10 -53 21 -25 11 -46 19 -48 18 -2 -1 -109 29
|
|
||||||
-137 40 -13 4 -32 9 -65 16 -5 1 -16 5 -22 9 -7 5 -13 6 -13 3 0 -2 -15 0 -32
|
|
||||||
5 -18 5 -44 11 -58 14 -14 3 -36 7 -50 10 -14 3 -50 9 -80 15 -30 6 -64 12
|
|
||||||
-75 14 -11 2 -45 6 -75 10 -30 4 -71 9 -90 12 -19 3 -53 6 -75 7 -22 1 -44 5
|
|
||||||
-50 8 -11 7 -542 9 -548 2z m57 -404 c7 10 436 8 511 -3 22 -3 60 -8 85 -11
|
|
||||||
25 -2 56 -6 70 -9 14 -2 43 -7 65 -10 38 -5 58 -9 115 -21 14 -3 34 -7 45 -9
|
|
||||||
11 -2 58 -14 105 -26 47 -12 92 -23 100 -25 35 -7 279 -94 308 -109 17 -9 34
|
|
||||||
-16 37 -16 3 1 20 -6 38 -14 17 -8 68 -31 112 -51 44 -20 82 -35 84 -35 2 1 7
|
|
||||||
-3 10 -8 3 -5 43 -28 88 -51 45 -23 87 -48 93 -56 7 -8 17 -15 22 -15 12 0
|
|
||||||
192 -121 196 -132 2 -4 8 -8 13 -8 10 0 119 -86 220 -172 102 -87 256 -244
|
|
||||||
349 -357 25 -30 53 -63 63 -73 9 -10 17 -22 17 -28 0 -5 3 -10 8 -10 4 0 25
|
|
||||||
-27 46 -60 22 -33 43 -60 48 -60 4 0 8 -5 8 -11 0 -6 11 -25 25 -43 14 -18 25
|
|
||||||
-38 25 -44 0 -7 4 -12 8 -12 5 0 16 -15 25 -32 9 -18 30 -55 47 -83 46 -77
|
|
||||||
161 -305 154 -305 -4 0 -2 -6 4 -12 6 -7 23 -47 40 -88 16 -41 33 -84 37 -95
|
|
||||||
5 -11 9 -22 10 -25 0 -3 11 -36 24 -73 13 -38 21 -70 19 -73 -3 -2 -1386 -3
|
|
||||||
-3075 -2 l-3071 3 38 110 c47 137 117 301 182 425 62 118 167 295 191 320 9
|
|
||||||
11 17 22 17 25 0 7 39 63 58 83 6 7 26 35 44 60 18 26 37 52 43 57 6 6 34 37
|
|
||||||
61 70 48 59 271 286 329 335 17 14 53 43 80 65 28 22 52 42 55 45 3 3 21 17
|
|
||||||
40 30 19 14 40 28 45 32 40 32 105 78 109 78 3 0 28 16 55 35 26 19 53 35 58
|
|
||||||
35 5 0 18 8 29 18 17 15 53 35 216 119 118 60 412 176 422 166 3 -4 6 -2 6 4
|
|
||||||
0 6 12 13 28 16 15 3 52 12 82 21 30 9 63 19 73 21 10 2 27 7 37 10 10 3 29 8
|
|
||||||
42 10 13 3 48 10 78 16 30 7 61 12 68 12 6 0 12 4 12 9 0 5 5 6 10 3 6 -4 34
|
|
||||||
-2 63 4 51 11 71 13 197 26 36 4 67 9 69 11 2 2 10 -1 17 -7 8 -6 14 -7 18 0z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.8 KiB |
@ -1,5 +1,7 @@
|
|||||||
import { Prisma, Report } from '@prisma/client';
|
import { Prisma, Report } from '@prisma/client';
|
||||||
|
import { REPORT_FILTER_TYPES } from 'lib/constants';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
|
import { FilterResult, ReportSearchFilter, ReportSearchFilterType, SearchFilter } from 'lib/types';
|
||||||
|
|
||||||
export async function createReport(data: Prisma.ReportUncheckedCreateInput): Promise<Report> {
|
export async function createReport(data: Prisma.ReportUncheckedCreateInput): Promise<Report> {
|
||||||
return prisma.client.report.create({ data });
|
return prisma.client.report.create({ data });
|
||||||
@ -13,22 +15,6 @@ export async function getReportById(reportId: string): Promise<Report> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserReports(userId: string): Promise<Report[]> {
|
|
||||||
return prisma.client.report.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWebsiteReports(websiteId: string): Promise<Report[]> {
|
|
||||||
return prisma.client.report.findMany({
|
|
||||||
where: {
|
|
||||||
websiteId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateReport(
|
export async function updateReport(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
data: Prisma.ReportUpdateInput,
|
data: Prisma.ReportUpdateInput,
|
||||||
@ -39,3 +25,103 @@ export async function updateReport(
|
|||||||
export async function deleteReport(reportId: string): Promise<Report> {
|
export async function deleteReport(reportId: string): Promise<Report> {
|
||||||
return prisma.client.report.delete({ where: { id: reportId } });
|
return prisma.client.report.delete({ where: { id: reportId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getReports(
|
||||||
|
ReportSearchFilter: ReportSearchFilter,
|
||||||
|
): Promise<FilterResult<Report[]>> {
|
||||||
|
const { userId, websiteId, filter, filterType = REPORT_FILTER_TYPES.all } = ReportSearchFilter;
|
||||||
|
const where: Prisma.ReportWhereInput = {
|
||||||
|
...(userId && { userId: userId }),
|
||||||
|
...(websiteId && { websiteId: websiteId }),
|
||||||
|
...(filter && {
|
||||||
|
AND: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
...((filterType === REPORT_FILTER_TYPES.all ||
|
||||||
|
filterType === REPORT_FILTER_TYPES.name) && {
|
||||||
|
name: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === REPORT_FILTER_TYPES.all ||
|
||||||
|
filterType === REPORT_FILTER_TYPES.description) && {
|
||||||
|
description: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === REPORT_FILTER_TYPES.all ||
|
||||||
|
filterType === REPORT_FILTER_TYPES.type) && {
|
||||||
|
type: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === REPORT_FILTER_TYPES.all ||
|
||||||
|
filterType === REPORT_FILTER_TYPES['user:username']) && {
|
||||||
|
user: {
|
||||||
|
username: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === REPORT_FILTER_TYPES.all ||
|
||||||
|
filterType === REPORT_FILTER_TYPES['website:name']) && {
|
||||||
|
website: {
|
||||||
|
name: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === REPORT_FILTER_TYPES.all ||
|
||||||
|
filterType === REPORT_FILTER_TYPES['website:domain']) && {
|
||||||
|
website: {
|
||||||
|
domain: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pageFilters, getParameters] = prisma.getPageFilters(ReportSearchFilter);
|
||||||
|
|
||||||
|
const reports = await prisma.client.report.findMany({
|
||||||
|
where,
|
||||||
|
...pageFilters,
|
||||||
|
});
|
||||||
|
const count = await prisma.client.report.count({
|
||||||
|
where,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: reports,
|
||||||
|
count,
|
||||||
|
...getParameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportsByUserId(
|
||||||
|
userId: string,
|
||||||
|
filter: SearchFilter<ReportSearchFilterType>,
|
||||||
|
): Promise<FilterResult<Report[]>> {
|
||||||
|
return getReports({ userId, ...filter });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportsByWebsiteId(
|
||||||
|
websiteId: string,
|
||||||
|
filter: SearchFilter<ReportSearchFilterType>,
|
||||||
|
): Promise<FilterResult<Report[]>> {
|
||||||
|
return getReports({ websiteId, ...filter });
|
||||||
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { Prisma, Team } from '@prisma/client';
|
import { Prisma, Team } from '@prisma/client';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import { ROLES } from 'lib/constants';
|
import { ROLES, TEAM_FILTER_TYPES } from 'lib/constants';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
|
import { FilterResult, TeamSearchFilter, TeamSearchFilterType, SearchFilter } from 'lib/types';
|
||||||
|
|
||||||
export interface GetTeamOptions {
|
export interface GetTeamOptions {
|
||||||
includeTeamUser?: boolean;
|
includeTeamUser?: boolean;
|
||||||
@ -26,12 +27,6 @@ export function getTeamByAccessCode(accessCode: string, options: GetTeamOptions
|
|||||||
return getTeam({ accessCode }, options);
|
return getTeam({ accessCode }, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTeams(where: Prisma.TeamWhereInput): Promise<Team[]> {
|
|
||||||
return prisma.client.team.findMany({
|
|
||||||
where,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<Team> {
|
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<Team> {
|
||||||
const { id } = data;
|
const { id } = data;
|
||||||
|
|
||||||
@ -85,3 +80,82 @@ export async function deleteTeam(
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTeams(
|
||||||
|
TeamSearchFilter: TeamSearchFilter,
|
||||||
|
options?: { include?: Prisma.TeamInclude },
|
||||||
|
): Promise<FilterResult<Team[]>> {
|
||||||
|
const { userId, filter, filterType = TEAM_FILTER_TYPES.all } = TeamSearchFilter;
|
||||||
|
const where: Prisma.TeamWhereInput = {
|
||||||
|
...(userId && {
|
||||||
|
teamUser: {
|
||||||
|
some: { userId },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(filter && {
|
||||||
|
AND: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
...((filterType === TEAM_FILTER_TYPES.all || filterType === TEAM_FILTER_TYPES.name) && {
|
||||||
|
name: { startsWith: filter },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === TEAM_FILTER_TYPES.all ||
|
||||||
|
filterType === TEAM_FILTER_TYPES['user:username']) && {
|
||||||
|
teamUser: {
|
||||||
|
every: {
|
||||||
|
role: ROLES.teamOwner,
|
||||||
|
user: {
|
||||||
|
username: {
|
||||||
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pageFilters, getParameters] = prisma.getPageFilters({
|
||||||
|
orderBy: 'name',
|
||||||
|
...TeamSearchFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teams = await prisma.client.team.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
},
|
||||||
|
...pageFilters,
|
||||||
|
...(options?.include && { include: options?.include }),
|
||||||
|
});
|
||||||
|
const count = await prisma.client.team.count({ where });
|
||||||
|
|
||||||
|
return { data: teams, count, ...getParameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamsByUserId(
|
||||||
|
userId: string,
|
||||||
|
filter?: SearchFilter<TeamSearchFilterType>,
|
||||||
|
): Promise<FilterResult<Team[]>> {
|
||||||
|
return getTeams(
|
||||||
|
{ userId, ...filter },
|
||||||
|
{
|
||||||
|
include: {
|
||||||
|
teamUser: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Prisma, Team, TeamUser } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { getRandomChars } from 'next-basics';
|
|
||||||
import cache from 'lib/cache';
|
import cache from 'lib/cache';
|
||||||
import { ROLES } from 'lib/constants';
|
import { ROLES, USER_FILTER_TYPES } from 'lib/constants';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
import { Website, User, Role } from 'lib/types';
|
import { FilterResult, Role, User, UserSearchFilter } from 'lib/types';
|
||||||
|
import { getRandomChars } from 'next-basics';
|
||||||
|
|
||||||
export interface GetUserOptions {
|
export interface GetUserOptions {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
@ -36,125 +36,59 @@ export async function getUserByUsername(username: string, options: GetUserOption
|
|||||||
return getUser({ username }, options);
|
return getUser({ username }, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsers(): Promise<User[]> {
|
export async function getUsers(
|
||||||
return prisma.client.user.findMany({
|
UserSearchFilter: UserSearchFilter = {},
|
||||||
take: 100,
|
options?: { include?: Prisma.UserInclude },
|
||||||
where: {
|
): Promise<FilterResult<User[]>> {
|
||||||
deletedAt: null,
|
const { teamId, filter, filterType = USER_FILTER_TYPES.all } = UserSearchFilter;
|
||||||
},
|
const where: Prisma.UserWhereInput = {
|
||||||
orderBy: [
|
...(teamId && {
|
||||||
{
|
|
||||||
username: 'asc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
role: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserTeams(userId: string): Promise<
|
|
||||||
(Team & {
|
|
||||||
teamUser: (TeamUser & {
|
|
||||||
user: { id: string; username: string };
|
|
||||||
})[];
|
|
||||||
})[]
|
|
||||||
> {
|
|
||||||
return prisma.client.team.findMany({
|
|
||||||
where: {
|
|
||||||
teamUser: {
|
teamUser: {
|
||||||
some: {
|
some: {
|
||||||
userId,
|
teamId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
include: {
|
...(filter && {
|
||||||
teamUser: {
|
AND: {
|
||||||
include: {
|
OR: [
|
||||||
user: {
|
{
|
||||||
select: {
|
...((filterType === USER_FILTER_TYPES.all ||
|
||||||
id: true,
|
filterType === USER_FILTER_TYPES.username) && {
|
||||||
username: true,
|
username: {
|
||||||
},
|
startsWith: filter,
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
|
};
|
||||||
|
const [pageFilters, getParameters] = prisma.getPageFilters({
|
||||||
|
orderBy: 'username',
|
||||||
|
...UserSearchFilter,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserWebsites(
|
const users = await prisma.client.user.findMany({
|
||||||
userId: string,
|
|
||||||
options?: { includeTeams: boolean },
|
|
||||||
): Promise<Website[]> {
|
|
||||||
const { rawQuery } = prisma;
|
|
||||||
|
|
||||||
if (options?.includeTeams) {
|
|
||||||
const websites = await rawQuery(
|
|
||||||
`
|
|
||||||
select
|
|
||||||
website_id as "id",
|
|
||||||
name,
|
|
||||||
domain,
|
|
||||||
share_id as "shareId",
|
|
||||||
reset_at as "resetAt",
|
|
||||||
user_id as "userId",
|
|
||||||
created_at as "createdAt",
|
|
||||||
updated_at as "updatedAt",
|
|
||||||
deleted_at as "deletedAt",
|
|
||||||
null as "teamId",
|
|
||||||
null as "teamName"
|
|
||||||
from website
|
|
||||||
where user_id = {{userId::uuid}}
|
|
||||||
and deleted_at is null
|
|
||||||
union
|
|
||||||
select
|
|
||||||
w.website_id as "id",
|
|
||||||
w.name,
|
|
||||||
w.domain,
|
|
||||||
w.share_id as "shareId",
|
|
||||||
w.reset_at as "resetAt",
|
|
||||||
w.user_id as "userId",
|
|
||||||
w.created_at as "createdAt",
|
|
||||||
w.updated_at as "updatedAt",
|
|
||||||
w.deleted_at as "deletedAt",
|
|
||||||
t.team_id as "teamId",
|
|
||||||
t.name as "teamName"
|
|
||||||
from website w
|
|
||||||
inner join team_website tw
|
|
||||||
on tw.website_id = w.website_id
|
|
||||||
inner join team t
|
|
||||||
on t.team_id = tw.team_id
|
|
||||||
inner join team_user tu
|
|
||||||
on tu.team_id = tw.team_id
|
|
||||||
where tu.user_id = {{userId::uuid}}
|
|
||||||
and w.deleted_at is null
|
|
||||||
`,
|
|
||||||
{ userId },
|
|
||||||
);
|
|
||||||
|
|
||||||
return websites.reduce((arr, item) => {
|
|
||||||
if (!arr.find(({ id }) => id === item.id)) {
|
|
||||||
return arr.concat(item);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.client.website.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
userId,
|
...where,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
orderBy: [
|
...pageFilters,
|
||||||
{
|
...(options?.include && { include: options.include }),
|
||||||
name: 'asc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
const count = await prisma.client.user.count({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: users as any, count, ...getParameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsersByTeamId(teamId: string, filter?: UserSearchFilter) {
|
||||||
|
return getUsers({ teamId, ...filter });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(data: {
|
export async function createUser(data: {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Prisma, Website } from '@prisma/client';
|
import { Prisma, Website } from '@prisma/client';
|
||||||
import cache from 'lib/cache';
|
import cache from 'lib/cache';
|
||||||
|
import { ROLES, WEBSITE_FILTER_TYPES } from 'lib/constants';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
|
import { FilterResult, WebsiteSearchFilter } from 'lib/types';
|
||||||
|
|
||||||
async function getWebsite(where: Prisma.WebsiteWhereUniqueInput): Promise<Website> {
|
async function getWebsite(where: Prisma.WebsiteWhereUniqueInput): Promise<Website> {
|
||||||
return prisma.client.website.findUnique({
|
return prisma.client.website.findUnique({
|
||||||
@ -16,11 +18,199 @@ export async function getWebsiteByShareId(shareId: string) {
|
|||||||
return getWebsite({ shareId });
|
return getWebsite({ shareId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWebsites(): Promise<Website[]> {
|
export async function getWebsites(
|
||||||
return prisma.client.website.findMany({
|
WebsiteSearchFilter: WebsiteSearchFilter,
|
||||||
orderBy: {
|
options?: { include?: Prisma.WebsiteInclude },
|
||||||
name: 'asc',
|
): Promise<FilterResult<Website[]>> {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
includeTeams,
|
||||||
|
filter,
|
||||||
|
filterType = WEBSITE_FILTER_TYPES.all,
|
||||||
|
} = WebsiteSearchFilter;
|
||||||
|
|
||||||
|
const filterQuery = {
|
||||||
|
AND: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
...((filterType === WEBSITE_FILTER_TYPES.all ||
|
||||||
|
filterType === WEBSITE_FILTER_TYPES.name) && {
|
||||||
|
name: { startsWith: filter },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...((filterType === WEBSITE_FILTER_TYPES.all ||
|
||||||
|
filterType === WEBSITE_FILTER_TYPES.domain) && {
|
||||||
|
domain: { startsWith: filter },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const where: Prisma.WebsiteWhereInput = {
|
||||||
|
...(teamId && {
|
||||||
|
teamWebsite: {
|
||||||
|
some: {
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
AND: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
...(userId && {
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...(includeTeams && {
|
||||||
|
teamWebsite: {
|
||||||
|
some: {
|
||||||
|
team: {
|
||||||
|
teamUser: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...(filter && filterQuery),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pageFilters, getParameters] = prisma.getPageFilters({
|
||||||
|
orderBy: 'name',
|
||||||
|
...WebsiteSearchFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const websites = await prisma.client.website.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
...pageFilters,
|
||||||
|
...(options?.include && { include: options.include }),
|
||||||
|
});
|
||||||
|
const count = await prisma.client.website.count({ where });
|
||||||
|
|
||||||
|
return { data: websites, count, ...getParameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebsitesByUserId(
|
||||||
|
userId: string,
|
||||||
|
filter?: WebsiteSearchFilter,
|
||||||
|
): Promise<FilterResult<Website[]>> {
|
||||||
|
return getWebsites({ userId, ...filter });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebsitesByTeamId(
|
||||||
|
teamId: string,
|
||||||
|
filter?: WebsiteSearchFilter,
|
||||||
|
): Promise<FilterResult<Website[]>> {
|
||||||
|
return getWebsites(
|
||||||
|
{
|
||||||
|
teamId,
|
||||||
|
...filter,
|
||||||
|
includeTeams: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
include: {
|
||||||
|
teamWebsite: {
|
||||||
|
include: {
|
||||||
|
team: {
|
||||||
|
include: {
|
||||||
|
teamUser: {
|
||||||
|
where: { role: ROLES.teamOwner },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserWebsites(
|
||||||
|
userId: string,
|
||||||
|
options?: { includeTeams: boolean },
|
||||||
|
): Promise<Website[]> {
|
||||||
|
const { rawQuery } = prisma;
|
||||||
|
|
||||||
|
if (options?.includeTeams) {
|
||||||
|
const websites = await rawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
website_id as "id",
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
share_id as "shareId",
|
||||||
|
reset_at as "resetAt",
|
||||||
|
user_id as "userId",
|
||||||
|
created_at as "createdAt",
|
||||||
|
updated_at as "updatedAt",
|
||||||
|
deleted_at as "deletedAt",
|
||||||
|
null as "teamId",
|
||||||
|
null as "teamName"
|
||||||
|
from website
|
||||||
|
where user_id = {{userId::uuid}}
|
||||||
|
and deleted_at is null
|
||||||
|
union
|
||||||
|
select
|
||||||
|
w.website_id as "id",
|
||||||
|
w.name,
|
||||||
|
w.domain,
|
||||||
|
w.share_id as "shareId",
|
||||||
|
w.reset_at as "resetAt",
|
||||||
|
w.user_id as "userId",
|
||||||
|
w.created_at as "createdAt",
|
||||||
|
w.updated_at as "updatedAt",
|
||||||
|
w.deleted_at as "deletedAt",
|
||||||
|
t.team_id as "teamId",
|
||||||
|
t.name as "teamName"
|
||||||
|
from website w
|
||||||
|
inner join team_website tw
|
||||||
|
on tw.website_id = w.website_id
|
||||||
|
inner join team t
|
||||||
|
on t.team_id = tw.team_id
|
||||||
|
inner join team_user tu
|
||||||
|
on tu.team_id = tw.team_id
|
||||||
|
where tu.user_id = {{userId::uuid}}
|
||||||
|
and w.deleted_at is null
|
||||||
|
`,
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
|
||||||
|
return websites.reduce((arr, item) => {
|
||||||
|
if (!arr.find(({ id }) => id === item.id)) {
|
||||||
|
return arr.concat(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.client.website.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,10 +21,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters & { fiel
|
|||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
event_key as fieldName,
|
event_key as "fieldName",
|
||||||
data_type as dataType,
|
data_type as "dataType",
|
||||||
string_value as fieldValue,
|
string_value as "fieldValue",
|
||||||
count(*) as total
|
count(*) as "total"
|
||||||
from event_data
|
from event_data
|
||||||
where website_id = {{websiteId::uuid}}
|
where website_id = {{websiteId::uuid}}
|
||||||
and created_at between {{startDate}} and {{endDate}}
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
166
queries/analytics/reports/getRetention.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import clickhouse from 'lib/clickhouse';
|
||||||
|
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
|
export async function getRetention(
|
||||||
|
...args: [
|
||||||
|
websiteId: string,
|
||||||
|
dateRange: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(
|
||||||
|
websiteId: string,
|
||||||
|
dateRange: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
date: Date;
|
||||||
|
day: number;
|
||||||
|
visitors: number;
|
||||||
|
returnVisitors: number;
|
||||||
|
percentage: number;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const { startDate, endDate } = dateRange;
|
||||||
|
const { rawQuery } = prisma;
|
||||||
|
|
||||||
|
return rawQuery(
|
||||||
|
`
|
||||||
|
WITH cohort_items AS (
|
||||||
|
select session_id,
|
||||||
|
date_trunc('day', created_at)::date as cohort_date
|
||||||
|
from session
|
||||||
|
where website_id = {{websiteId::uuid}}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
),
|
||||||
|
user_activities AS (
|
||||||
|
select distinct
|
||||||
|
w.session_id,
|
||||||
|
(date_trunc('day', w.created_at)::date - c.cohort_date::date) as day_number
|
||||||
|
from website_event w
|
||||||
|
join cohort_items c
|
||||||
|
on w.session_id = c.session_id
|
||||||
|
where website_id = {{websiteId::uuid}}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
),
|
||||||
|
cohort_size as (
|
||||||
|
select cohort_date,
|
||||||
|
count(*) as visitors
|
||||||
|
from cohort_items
|
||||||
|
group by 1
|
||||||
|
order by 1
|
||||||
|
),
|
||||||
|
cohort_date as (
|
||||||
|
select
|
||||||
|
c.cohort_date,
|
||||||
|
a.day_number,
|
||||||
|
count(*) as visitors
|
||||||
|
from user_activities a
|
||||||
|
join cohort_items c
|
||||||
|
on a.session_id = c.session_id
|
||||||
|
where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30)
|
||||||
|
group by 1, 2
|
||||||
|
)
|
||||||
|
select
|
||||||
|
c.cohort_date as date,
|
||||||
|
c.day_number as day,
|
||||||
|
s.visitors,
|
||||||
|
c.visitors as "returnVisitors",
|
||||||
|
c.visitors::float * 100 / s.visitors as percentage
|
||||||
|
from cohort_date c
|
||||||
|
join cohort_size s
|
||||||
|
on c.cohort_date = s.cohort_date
|
||||||
|
order by 1, 2`,
|
||||||
|
{
|
||||||
|
websiteId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickhouseQuery(
|
||||||
|
websiteId: string,
|
||||||
|
dateRange: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
date: Date;
|
||||||
|
day: number;
|
||||||
|
visitors: number;
|
||||||
|
returnVisitors: number;
|
||||||
|
percentage: number;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const { startDate, endDate } = dateRange;
|
||||||
|
const { rawQuery } = clickhouse;
|
||||||
|
|
||||||
|
return rawQuery(
|
||||||
|
`
|
||||||
|
WITH cohort_items AS (
|
||||||
|
select
|
||||||
|
min(date_trunc('day', created_at)) as cohort_date,
|
||||||
|
session_id
|
||||||
|
from website_event
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
group by session_id
|
||||||
|
),
|
||||||
|
user_activities AS (
|
||||||
|
select distinct
|
||||||
|
w.session_id,
|
||||||
|
(date_trunc('day', w.created_at) - c.cohort_date) / 86400 as day_number
|
||||||
|
from website_event w
|
||||||
|
join cohort_items c
|
||||||
|
on w.session_id = c.session_id
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
),
|
||||||
|
cohort_size as (
|
||||||
|
select cohort_date,
|
||||||
|
count(*) as visitors
|
||||||
|
from cohort_items
|
||||||
|
group by 1
|
||||||
|
order by 1
|
||||||
|
),
|
||||||
|
cohort_date as (
|
||||||
|
select
|
||||||
|
c.cohort_date,
|
||||||
|
a.day_number,
|
||||||
|
count(*) as visitors
|
||||||
|
from user_activities a
|
||||||
|
join cohort_items c
|
||||||
|
on a.session_id = c.session_id
|
||||||
|
where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30)
|
||||||
|
group by 1, 2
|
||||||
|
)
|
||||||
|
select
|
||||||
|
c.cohort_date as date,
|
||||||
|
c.day_number as day,
|
||||||
|
s.visitors as visitors,
|
||||||
|
c.visitors returnVisitors,
|
||||||
|
c.visitors * 100 / s.visitors as percentage
|
||||||
|
from cohort_date c
|
||||||
|
join cohort_size s
|
||||||
|
on c.cohort_date = s.cohort_date
|
||||||
|
order by 1, 2`,
|
||||||
|
{
|
||||||
|
websiteId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataFields';
|
|||||||
export * from './analytics/eventData/getEventDataUsage';
|
export * from './analytics/eventData/getEventDataUsage';
|
||||||
export * from './analytics/events/saveEvent';
|
export * from './analytics/events/saveEvent';
|
||||||
export * from './analytics/reports/getFunnel';
|
export * from './analytics/reports/getFunnel';
|
||||||
|
export * from './analytics/reports/getRetention';
|
||||||
export * from './analytics/reports/getInsights';
|
export * from './analytics/reports/getInsights';
|
||||||
export * from './analytics/pageviews/getPageviewMetrics';
|
export * from './analytics/pageviews/getPageviewMetrics';
|
||||||
export * from './analytics/pageviews/getPageviewStats';
|
export * from './analytics/pageviews/getPageviewStats';
|
||||||
|