diff --git a/.eslintrc.json b/.eslintrc.json index 7a824ff6..25e83d5a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -50,7 +50,8 @@ "@next/next/no-img-element": "off", "@typescript-eslint/no-empty-function": "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": { "React": "writable" diff --git a/components/common/Pager.js b/components/common/Pager.js new file mode 100644 index 00000000..584e0669 --- /dev/null +++ b/components/common/Pager.js @@ -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 ( + + + {`Page ${page} of ${maxPage}`} + + + ); +} + +export default Pager; diff --git a/components/common/Pager.module.css b/components/common/Pager.module.css new file mode 100644 index 00000000..b4ee9f0e --- /dev/null +++ b/components/common/Pager.module.css @@ -0,0 +1,7 @@ +.container { + margin-top: 20px; +} + +.text { + margin: 0 10px; +} diff --git a/components/common/SettingsTable.js b/components/common/SettingsTable.js index 8f039858..a57919f1 100644 --- a/components/common/SettingsTable.js +++ b/components/common/SettingsTable.js @@ -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 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 ( - - - {(column, index) => { - return ( - - {column.label} - - ); - }} - - - {(row, keys, rowIndex) => { - row.action = children(row, keys, rowIndex); + <> + {showSearch && ( + + )} + {value.length === 0 && filterValue && ( + + )} + {value.length > 0 && ( +
+ + {(column, index) => { + return ( + + {column.label} + + ); + }} + + + {(row, keys, rowIndex) => { + row.action = children(row, keys, rowIndex); - return ( - - {(data, key, colIndex) => { - return ( - - - {cellRender ? cellRender(row, data, key, colIndex) : data[key]} - - ); - }} - - ); - }} - -
+ return ( + + {(data, key, colIndex) => { + return ( + + + {cellRender ? cellRender(row, data, key, colIndex) : data[key]} + + ); + }} + + ); + }} + + {showPaging && ( + + )} + + )} + ); } diff --git a/components/input/WebsiteSelect.js b/components/input/WebsiteSelect.js index b77ae57c..ae3ceb46 100644 --- a/components/input/WebsiteSelect.js +++ b/components/input/WebsiteSelect.js @@ -8,12 +8,12 @@ export function WebsiteSelect({ websiteId, onSelect }) { const { data } = useQuery(['websites:me'], () => get('/me/websites')); const renderValue = value => { - return data?.find(({ id }) => id === value)?.name; + return data?.data?.find(({ id }) => id === value)?.name; }; return ( - 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(); function handleMore() { @@ -47,8 +48,10 @@ export function Dashboard({ userId }) { )} {hasData && ( <> - {editing && } - {!editing && } + {editing && } + {!editing && ( + + )} {max < data.length && ( - - + {hasData && ( +
+ + + {({ id, name }) => {name}} + + + + + + {formatMessage(labels.addWebsite)} + + + + + )} ); } diff --git a/components/pages/settings/teams/TeamMembers.js b/components/pages/settings/teams/TeamMembers.js index 3ea8232c..9762ef29 100644 --- a/components/pages/settings/teams/TeamMembers.js +++ b/components/pages/settings/teams/TeamMembers.js @@ -2,13 +2,22 @@ import { Loading, useToasts } from 'react-basics'; import TeamMembersTable from 'components/pages/settings/teams/TeamMembersTable'; import useApi from 'hooks/useApi'; import useMessages from 'hooks/useMessages'; +import useApiFilter from 'hooks/useApiFilter'; export function TeamMembers({ teamId, readOnly }) { const { showToast } = useToasts(); - const { get, useQuery } = useApi(); const { formatMessage, messages } = useMessages(); - const { data, isLoading, refetch } = useQuery(['teams:users', teamId], () => - get(`/teams/${teamId}/users`), + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + 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) { @@ -22,7 +31,15 @@ export function TeamMembers({ teamId, readOnly }) { return ( <> - + ); } diff --git a/components/pages/settings/teams/TeamMembersTable.js b/components/pages/settings/teams/TeamMembersTable.js index 8e6fad82..daa4acc6 100644 --- a/components/pages/settings/teams/TeamMembersTable.js +++ b/components/pages/settings/teams/TeamMembersTable.js @@ -4,7 +4,15 @@ import { ROLES } from 'lib/constants'; import TeamMemberRemoveButton from './TeamMemberRemoveButton'; 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 { user } = useUser(); @@ -16,7 +24,7 @@ export function TeamMembersTable({ data = [], onSave, readOnly }) { const cellRender = (row, data, key) => { if (key === 'username') { - return row?.user?.username; + return row?.username; } if (key === 'role') { return formatMessage( @@ -27,13 +35,23 @@ export function TeamMembersTable({ data = [], onSave, readOnly }) { }; return ( - + {row => { return ( !readOnly && ( diff --git a/components/pages/settings/teams/TeamWebsites.js b/components/pages/settings/teams/TeamWebsites.js index 9a5761e5..2ae344f5 100644 --- a/components/pages/settings/teams/TeamWebsites.js +++ b/components/pages/settings/teams/TeamWebsites.js @@ -13,13 +13,22 @@ import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable import TeamAddWebsiteForm from 'components/pages/settings/teams/TeamAddWebsiteForm'; import useApi from 'hooks/useApi'; import useMessages from 'hooks/useMessages'; +import useApiFilter from 'hooks/useApiFilter'; export function TeamWebsites({ teamId }) { const { showToast } = useToasts(); const { formatMessage, labels, messages } = useMessages(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); const { get, useQuery } = useApi(); - const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () => - get(`/teams/${teamId}/websites`), + const { data, isLoading, refetch } = useQuery( + ['teams:websites', teamId, filter, page, pageSize], + () => + get(`/teams/${teamId}/websites`, { + filter, + page, + pageSize, + }), ); const hasData = data && data.length !== 0; @@ -49,7 +58,17 @@ export function TeamWebsites({ teamId }) { return (
{addButton} - {hasData && } + {hasData && ( + + )}
); } diff --git a/components/pages/settings/teams/TeamWebsitesTable.js b/components/pages/settings/teams/TeamWebsitesTable.js index 4873c6c7..564c8a78 100644 --- a/components/pages/settings/teams/TeamWebsitesTable.js +++ b/components/pages/settings/teams/TeamWebsitesTable.js @@ -6,9 +6,17 @@ import TeamWebsiteRemoveButton from './TeamWebsiteRemoveButton'; import SettingsTable from 'components/common/SettingsTable'; import useConfig from 'hooks/useConfig'; -export function TeamWebsitesTable({ data = [], onSave }) { +export function TeamWebsitesTable({ + data = [], + onSave, + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { openExternal } = useConfig(); + const { user } = useUser(); const columns = [ { name: 'name', label: formatMessage(labels.name) }, @@ -17,11 +25,19 @@ export function TeamWebsitesTable({ data = [], onSave }) { ]; return ( - + {row => { - const { teamId } = row; - const { id: websiteId, name, domain, userId } = row.website; - const { teamUser } = row.team; + const { id: teamId, teamUser } = row.teamWebsite[0].team; + const { id: websiteId, name, domain, userId } = row; const owner = teamUser[0]; const canRemove = user.id === userId || user.id === owner.userId; diff --git a/components/pages/settings/teams/TeamsList.js b/components/pages/settings/teams/TeamsList.js index 0c82639b..061100f6 100644 --- a/components/pages/settings/teams/TeamsList.js +++ b/components/pages/settings/teams/TeamsList.js @@ -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 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 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 useMessages from 'hooks/useMessages'; -import { ROLES } from 'lib/constants'; 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() { const { user } = useUser(); const { formatMessage, labels, messages } = useMessages(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); const [update, setUpdate] = useState(0); + const { get, useQuery } = useApi(); - const { data, isLoading, error } = useQuery(['teams', update], () => get(`/teams`)); - const hasData = data && data.length !== 0; + const { data, isLoading, error } = useQuery(['teams', update, filter, page, pageSize], () => { + return get(`/teams`, { + filter, + page, + pageSize, + }); + }); + + const hasData = data && data?.data.length !== 0; + const isFiltered = filter; + const { showToast } = useToasts(); const handleSave = () => { @@ -71,15 +84,26 @@ export default function TeamsList() { return ( - {hasData && ( + {(hasData || isFiltered) && ( {joinButton} {createButton} )} - {hasData && } - {!hasData && ( + + {(hasData || isFiltered) && ( + + )} + + {!hasData && !isFiltered && ( {joinButton} diff --git a/components/pages/settings/teams/TeamsTable.js b/components/pages/settings/teams/TeamsTable.js index a344fefc..e35fb839 100644 --- a/components/pages/settings/teams/TeamsTable.js +++ b/components/pages/settings/teams/TeamsTable.js @@ -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 { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics'; import TeamDeleteForm from './TeamDeleteForm'; 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 { user } = useUser(); const { dir } = useLocale(); @@ -27,7 +34,17 @@ export function TeamsTable({ data = [], onDelete }) { }; return ( - + {row => { const { id, teamUser } = row; const owner = teamUser.find(({ role }) => role === ROLES.teamOwner); diff --git a/components/pages/settings/users/UsersList.js b/components/pages/settings/users/UsersList.js index 8886203b..614aabef 100644 --- a/components/pages/settings/users/UsersList.js +++ b/components/pages/settings/users/UsersList.js @@ -7,14 +7,27 @@ import UserAddButton from './UserAddButton'; import useApi from 'hooks/useApi'; import useUser from 'hooks/useUser'; import useMessages from 'hooks/useMessages'; +import useApiFilter from 'hooks/useApiFilter'; export function UsersList() { const { formatMessage, labels, messages } = useMessages(); const { user } = useUser(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { get, useQuery } = useApi(); - const { data, isLoading, error, refetch } = useQuery(['user'], () => get(`/users`), { - enabled: !!user, - }); + const { data, isLoading, error, refetch } = useQuery( + ['user', filter, page, pageSize], + () => + get(`/users`, { + filter, + page, + pageSize, + }), + { + enabled: !!user, + }, + ); const { showToast } = useToasts(); const hasData = data && data.length !== 0; @@ -33,8 +46,17 @@ export function UsersList() { - {hasData && } - {!hasData && ( + {(hasData || filter) && ( + + )} + {!hasData && !filter && ( diff --git a/components/pages/settings/users/UsersTable.js b/components/pages/settings/users/UsersTable.js index 2023efc5..f4c9dd77 100644 --- a/components/pages/settings/users/UsersTable.js +++ b/components/pages/settings/users/UsersTable.js @@ -8,7 +8,14 @@ import useMessages from 'hooks/useMessages'; import SettingsTable from 'components/common/SettingsTable'; 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 { user } = useUser(); const { dateLocale } = useLocale(); @@ -36,7 +43,17 @@ export function UsersTable({ data = [], onDelete }) { }; return ( - + {(row, keys, rowIndex) => { return ( <> diff --git a/components/pages/settings/websites/WebsitesList.js b/components/pages/settings/websites/WebsitesList.js index de423d0b..310b481f 100644 --- a/components/pages/settings/websites/WebsitesList.js +++ b/components/pages/settings/websites/WebsitesList.js @@ -8,14 +8,22 @@ import useApi from 'hooks/useApi'; import useUser from 'hooks/useUser'; import useMessages from 'hooks/useMessages'; import { ROLES } from 'lib/constants'; +import useApiFilter from 'hooks/useApiFilter'; export function WebsitesList() { const { formatMessage, labels, messages } = useMessages(); const { user } = useUser(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); const { get, useQuery } = useApi(); const { data, isLoading, error, refetch } = useQuery( - ['websites', user?.id], - () => get(`/users/${user?.id}/websites`), + ['websites', user?.id, filter, page, pageSize], + () => + get(`/users/${user?.id}/websites`, { + filter, + page, + pageSize, + }), { enabled: !!user }, ); const { showToast } = useToasts(); @@ -47,7 +55,15 @@ export function WebsitesList() { return ( {addButton} - {hasData && } + {hasData && ( + + )} {!hasData && ( {addButton} diff --git a/components/pages/settings/websites/WebsitesTable.js b/components/pages/settings/websites/WebsitesTable.js index 902393e6..aa8cbe8a 100644 --- a/components/pages/settings/websites/WebsitesTable.js +++ b/components/pages/settings/websites/WebsitesTable.js @@ -4,7 +4,13 @@ import SettingsTable from 'components/common/SettingsTable'; import useMessages from 'hooks/useMessages'; import useConfig from 'hooks/useConfig'; -export function WebsitesTable({ data = [] }) { +export function WebsitesTable({ + data = [], + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { openExternal } = useConfig(); @@ -15,7 +21,16 @@ export function WebsitesTable({ data = [] }) { ]; return ( - + {row => { const { id } = row; diff --git a/components/pages/websites/WebsiteReportsPage.js b/components/pages/websites/WebsiteReportsPage.js index 56927028..a1d49d10 100644 --- a/components/pages/websites/WebsiteReportsPage.js +++ b/components/pages/websites/WebsiteReportsPage.js @@ -7,7 +7,16 @@ import WebsiteHeader from './WebsiteHeader'; export function WebsiteReportsPage({ websiteId }) { 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 => { await deleteReport(id); @@ -26,7 +35,14 @@ export function WebsiteReportsPage({ websiteId }) { - + ); } diff --git a/hooks/useApiFilter.ts b/hooks/useApiFilter.ts new file mode 100644 index 00000000..d411fd43 --- /dev/null +++ b/hooks/useApiFilter.ts @@ -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; diff --git a/hooks/useReports.js b/hooks/useReports.js index f4369eec..57d76492 100644 --- a/hooks/useReports.js +++ b/hooks/useReports.js @@ -1,12 +1,16 @@ import { useState } from 'react'; import useApi from './useApi'; +import useApiFilter from 'hooks/useApiFilter'; export function useReports(websiteId) { const [modified, setModified] = useState(Date.now()); const { get, useQuery, del, useMutation } = useApi(); const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); - const { data, error, isLoading } = useQuery(['reports:website', { websiteId, modified }], () => - get(`/reports`, { websiteId }), + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { data, error, isLoading } = useQuery( + ['reports:website', { websiteId, modified, filter, page, pageSize }], + () => get(`/reports`, { websiteId, filter, page, pageSize }), ); 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; diff --git a/lib/constants.ts b/lib/constants.ts index 8972f81f..6eb4c4e4 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -30,6 +30,22 @@ export const FILTER_RANGE = 'filter-range'; export const FILTER_REFERRERS = 'filter-referrers'; 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 SESSION_COLUMNS = [ diff --git a/lib/prisma.ts b/lib/prisma.ts index a4993286..efce3f4e 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -4,7 +4,7 @@ import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; import { FILTER_COLUMNS, SESSION_COLUMNS, OPERATORS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; -import { QueryFilters, QueryOptions } from './types'; +import { QueryFilters, QueryOptions, SearchFilter } from './types'; const MYSQL_DATE_FORMATS = { minute: '%Y-%m-%d %H:%i:00', @@ -146,6 +146,37 @@ async function rawQuery(sql: string, data: object): Promise { return prisma.rawQuery(query, params); } +function getPageFilters(filters: SearchFilter): [ + { + 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 { ...prisma, getAddMinutesQuery, @@ -153,5 +184,6 @@ export default { getTimestampIntervalQuery, getFilterQuery, parseFilters, + getPageFilters, rawQuery, }; diff --git a/lib/types.ts b/lib/types.ts index dc54fd47..5a25169a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,17 +1,62 @@ 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[keyof T]; export type CollectionType = ObjectValues; - export type Role = ObjectValues; - export type EventType = ObjectValues; - export type DynamicDataType = ObjectValues; - export type KafkaTopic = ObjectValues; +export type ReportSearchFilterType = ObjectValues; +export type UserSearchFilterType = ObjectValues; +export type WebsiteSearchFilterType = ObjectValues; +export type TeamSearchFilterType = ObjectValues; + +export interface WebsiteSearchFilter extends SearchFilter { + userId?: string; + teamId?: string; + includeTeams?: boolean; +} + +export interface UserSearchFilter extends SearchFilter { + teamId?: string; +} + +export interface TeamSearchFilter extends SearchFilter { + userId?: string; +} + +export interface ReportSearchFilter extends SearchFilter { + userId?: string; + websiteId?: string; +} + +export interface SearchFilter { + filter?: string; + filterType?: T; + pageSize?: number; + page?: number; + orderBy?: string; +} + +export interface FilterResult { + data: T; + count: number; + pageSize: number; + page: number; + orderBy?: string; +} export interface DynamicData { [key: string]: number | string | DynamicData | number[] | string[] | DynamicData[]; diff --git a/pages/api/me/teams.ts b/pages/api/me/teams.ts index 36699016..d323043b 100644 --- a/pages/api/me/teams.ts +++ b/pages/api/me/teams.ts @@ -1,10 +1,15 @@ import { useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed } from 'next-basics'; import userTeams from 'pages/api/users/[id]/teams'; -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { +export interface MyTeamsRequestQuery extends SearchFilter {} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { await useCors(req, res); if (req.method === 'GET') { diff --git a/pages/api/me/websites.ts b/pages/api/me/websites.ts index 29f1e431..f9ccbcab 100644 --- a/pages/api/me/websites.ts +++ b/pages/api/me/websites.ts @@ -1,11 +1,16 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed } from 'next-basics'; import userWebsites from 'pages/api/users/[id]/websites'; -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { +export interface MyWebsitesRequestQuery extends SearchFilter {} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { await useCors(req, res); await useAuth(req, res); diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts index c856b565..8c6825f1 100644 --- a/pages/api/reports/index.ts +++ b/pages/api/reports/index.ts @@ -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 { 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 {} export interface ReportRequestBody { websiteId: string; @@ -35,7 +37,13 @@ export default async ( 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); } diff --git a/pages/api/reports/retention.ts b/pages/api/reports/retention.ts new file mode 100644 index 00000000..83ed0b57 --- /dev/null +++ b/pages/api/reports/retention.ts @@ -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, + res: NextApiResponse, +) => { + 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); +}; diff --git a/pages/api/teams/[id]/users/index.ts b/pages/api/teams/[id]/users/index.ts index c73da683..6f8b077e 100644 --- a/pages/api/teams/[id]/users/index.ts +++ b/pages/api/teams/[id]/users/index.ts @@ -1,11 +1,11 @@ import { canUpdateTeam, canViewTeam } from 'lib/auth'; import { useAuth } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; 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 { id: string; } @@ -27,7 +27,13 @@ export default async ( 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); } diff --git a/pages/api/teams/[id]/websites/index.ts b/pages/api/teams/[id]/websites/index.ts index 63be478b..dcd08939 100644 --- a/pages/api/teams/[id]/websites/index.ts +++ b/pages/api/teams/[id]/websites/index.ts @@ -1,11 +1,12 @@ import { canViewTeam } from 'lib/auth'; import { useAuth } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; 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 { id: string; } @@ -26,7 +27,13 @@ export default async ( 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); } diff --git a/pages/api/teams/index.ts b/pages/api/teams/index.ts index 453f1ef3..997ed885 100644 --- a/pages/api/teams/index.ts +++ b/pages/api/teams/index.ts @@ -1,18 +1,19 @@ import { Team } from '@prisma/client'; -import { NextApiRequestQueryBody } from 'lib/types'; import { canCreateTeam } from 'lib/auth'; import { uuid } from 'lib/crypto'; import { useAuth } from 'lib/middleware'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; 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 {} +export interface TeamsRequestBody extends SearchFilter { name: string; } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useAuth(req, res); @@ -22,9 +23,11 @@ export default async ( } = req.auth; 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') { diff --git a/pages/api/users/[id]/teams.ts b/pages/api/users/[id]/teams.ts index c31b98ca..831a992d 100644 --- a/pages/api/users/[id]/teams.ts +++ b/pages/api/users/[id]/teams.ts @@ -1,17 +1,21 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUserTeams } from 'queries'; +import { getTeamsByUserId } from 'queries'; -export interface UserWebsitesRequestBody { +export interface UserTeamsRequestQuery extends SearchFilter { + id: string; +} + +export interface UserTeamsRequestBody { name: string; domain: string; shareId: string; } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); @@ -25,7 +29,13 @@ export default async ( 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); } diff --git a/pages/api/users/[id]/websites.ts b/pages/api/users/[id]/websites.ts index e94094a4..72d793d1 100644 --- a/pages/api/users/[id]/websites.ts +++ b/pages/api/users/[id]/websites.ts @@ -1,9 +1,12 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUserWebsites } from 'queries'; +import { getWebsitesByUserId } from 'queries'; +export interface UserWebsitesRequestQuery extends SearchFilter { + id: string; +} export interface UserWebsitesRequestBody { name: string; domain: string; @@ -16,17 +19,21 @@ export default async ( ) => { await useCors(req, res); await useAuth(req, res); + const { user } = req.auth; - const { id: userId } = req.query; + const { id: userId, page, filter, pageSize, includeTeams } = req.query; if (req.method === 'GET') { if (!user.isAdmin && user.id !== userId) { return unauthorized(res); } - const { includeTeams } = req.query; - - const websites = await getUserWebsites(userId, { includeTeams }); + const websites = await getWebsitesByUserId(userId, { + page, + filter, + pageSize: +pageSize || null, + includeTeams, + }); return ok(res, websites); } diff --git a/pages/api/users/index.ts b/pages/api/users/index.ts index 6f6c205f..5e913c02 100644 --- a/pages/api/users/index.ts +++ b/pages/api/users/index.ts @@ -2,11 +2,12 @@ import { canCreateUser, canViewUsers } from 'lib/auth'; import { ROLES } from 'lib/constants'; import { uuid } from 'lib/crypto'; 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 { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics'; import { createUser, getUserByUsername, getUsers } from 'queries'; +export interface UsersRequestQuery extends SearchFilter {} export interface UsersRequestBody { username: string; password: string; @@ -15,7 +16,7 @@ export interface UsersRequestBody { } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useAuth(req, res); @@ -25,7 +26,9 @@ export default async ( 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); } diff --git a/pages/api/websites/index.ts b/pages/api/websites/index.ts index c8b5aba2..f94fa037 100644 --- a/pages/api/websites/index.ts +++ b/pages/api/websites/index.ts @@ -1,12 +1,14 @@ import { canCreateWebsite } from 'lib/auth'; import { uuid } from 'lib/crypto'; import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { createWebsite } from 'queries'; import userWebsites from 'pages/api/users/[id]/websites'; +export interface WebsitesRequestQuery extends SearchFilter {} + export interface WebsitesRequestBody { name: string; domain: string; @@ -14,7 +16,7 @@ export interface WebsitesRequestBody { } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); @@ -26,6 +28,7 @@ export default async ( if (req.method === 'GET') { req.query.id = userId; + req.query.pageSize = 100; return userWebsites(req, res); } diff --git a/pages/reports/retention.js b/pages/reports/retention.js new file mode 100644 index 00000000..b7f0bd0f --- /dev/null +++ b/pages/reports/retention.js @@ -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 ( + + + + ); +} diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 36a3bb8c..29d134ee 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index aa9bebab..e9253204 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 16842d3c..575bba77 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index 9d08d996..54964246 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 3f2e4c1f..ec5eceda 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/images/browsers/android.png b/public/images/browsers/android.png index 6e28498d..393001a0 100644 Binary files a/public/images/browsers/android.png and b/public/images/browsers/android.png differ diff --git a/public/images/browsers/aol.png b/public/images/browsers/aol.png index 66dc4288..3b6f962f 100644 Binary files a/public/images/browsers/aol.png and b/public/images/browsers/aol.png differ diff --git a/public/images/browsers/beaker.png b/public/images/browsers/beaker.png index fbc997cd..8d4e61d6 100644 Binary files a/public/images/browsers/beaker.png and b/public/images/browsers/beaker.png differ diff --git a/public/images/browsers/blackberry.png b/public/images/browsers/blackberry.png index 74f255cb..11ccd836 100644 Binary files a/public/images/browsers/blackberry.png and b/public/images/browsers/blackberry.png differ diff --git a/public/images/browsers/curl.png b/public/images/browsers/curl.png index dd221927..cc0a617a 100644 Binary files a/public/images/browsers/curl.png and b/public/images/browsers/curl.png differ diff --git a/public/images/browsers/edge.png b/public/images/browsers/edge.png index 3881a7e0..2da3aaff 100644 Binary files a/public/images/browsers/edge.png and b/public/images/browsers/edge.png differ diff --git a/public/images/browsers/instagram.png b/public/images/browsers/instagram.png index 5961a6b3..8377710a 100644 Binary files a/public/images/browsers/instagram.png and b/public/images/browsers/instagram.png differ diff --git a/public/images/browsers/ios-webview.png b/public/images/browsers/ios-webview.png index 5f2dd401..bf7c362e 100644 Binary files a/public/images/browsers/ios-webview.png and b/public/images/browsers/ios-webview.png differ diff --git a/public/images/browsers/ios.png b/public/images/browsers/ios.png index 5f2dd401..114bf554 100644 Binary files a/public/images/browsers/ios.png and b/public/images/browsers/ios.png differ diff --git a/public/images/browsers/miui.png b/public/images/browsers/miui.png index 5f929510..1534a0ca 100644 Binary files a/public/images/browsers/miui.png and b/public/images/browsers/miui.png differ diff --git a/public/images/browsers/searchbot.png b/public/images/browsers/searchbot.png index 46a33055..43b1dff9 100644 Binary files a/public/images/browsers/searchbot.png and b/public/images/browsers/searchbot.png differ diff --git a/public/images/os/amazon-os.png b/public/images/os/amazon-os.png index 9b18cf0f..030e72f6 100644 Binary files a/public/images/os/amazon-os.png and b/public/images/os/amazon-os.png differ diff --git a/public/images/os/android-os.png b/public/images/os/android-os.png index fc6509b3..21ea3445 100644 Binary files a/public/images/os/android-os.png and b/public/images/os/android-os.png differ diff --git a/public/images/os/beos.png b/public/images/os/beos.png index 6bc4a8a5..8e852b0f 100644 Binary files a/public/images/os/beos.png and b/public/images/os/beos.png differ diff --git a/public/images/os/blackberry-os.png b/public/images/os/blackberry-os.png index c77db525..093a8a40 100644 Binary files a/public/images/os/blackberry-os.png and b/public/images/os/blackberry-os.png differ diff --git a/public/images/os/ios.png b/public/images/os/ios.png index 1c129ae8..7e803a69 100644 Binary files a/public/images/os/ios.png and b/public/images/os/ios.png differ diff --git a/public/images/os/linux.png b/public/images/os/linux.png index ce8fba38..d5c1bd8b 100644 Binary files a/public/images/os/linux.png and b/public/images/os/linux.png differ diff --git a/public/images/os/mac-os.png b/public/images/os/mac-os.png index 1972abe7..e57d01cf 100644 Binary files a/public/images/os/mac-os.png and b/public/images/os/mac-os.png differ diff --git a/public/images/os/open-bsd.png b/public/images/os/open-bsd.png index 806887e8..b3423cce 100644 Binary files a/public/images/os/open-bsd.png and b/public/images/os/open-bsd.png differ diff --git a/public/images/os/os-2.png b/public/images/os/os-2.png index 5f88105d..8f51e618 100644 Binary files a/public/images/os/os-2.png and b/public/images/os/os-2.png differ diff --git a/public/images/os/qnx.png b/public/images/os/qnx.png index 59d9a44c..1cf10fe5 100644 Binary files a/public/images/os/qnx.png and b/public/images/os/qnx.png differ diff --git a/public/images/os/sun-os.png b/public/images/os/sun-os.png index c19f0eb3..648eb241 100644 Binary files a/public/images/os/sun-os.png and b/public/images/os/sun-os.png differ diff --git a/public/images/os/windows-2000.png b/public/images/os/windows-2000.png index 3bccae3f..8ec7db18 100644 Binary files a/public/images/os/windows-2000.png and b/public/images/os/windows-2000.png differ diff --git a/public/images/os/windows-3-11.png b/public/images/os/windows-3-11.png index 3bccae3f..8ec7db18 100644 Binary files a/public/images/os/windows-3-11.png and b/public/images/os/windows-3-11.png differ diff --git a/public/images/os/windows-7.png b/public/images/os/windows-7.png index cd2db79e..4a899a30 100644 Binary files a/public/images/os/windows-7.png and b/public/images/os/windows-7.png differ diff --git a/public/images/os/windows-8-1.png b/public/images/os/windows-8-1.png index 3ce98aaa..f6605f4c 100644 Binary files a/public/images/os/windows-8-1.png and b/public/images/os/windows-8-1.png differ diff --git a/public/images/os/windows-8.png b/public/images/os/windows-8.png index 3ce98aaa..f6605f4c 100644 Binary files a/public/images/os/windows-8.png and b/public/images/os/windows-8.png differ diff --git a/public/images/os/windows-95.png b/public/images/os/windows-95.png index 3bccae3f..8ec7db18 100644 Binary files a/public/images/os/windows-95.png and b/public/images/os/windows-95.png differ diff --git a/public/images/os/windows-98.png b/public/images/os/windows-98.png index 3bccae3f..8ec7db18 100644 Binary files a/public/images/os/windows-98.png and b/public/images/os/windows-98.png differ diff --git a/public/images/os/windows-me.png b/public/images/os/windows-me.png index cd2db79e..4a899a30 100644 Binary files a/public/images/os/windows-me.png and b/public/images/os/windows-me.png differ diff --git a/public/images/os/windows-server-2003.png b/public/images/os/windows-server-2003.png index cd2db79e..4a899a30 100644 Binary files a/public/images/os/windows-server-2003.png and b/public/images/os/windows-server-2003.png differ diff --git a/public/images/os/windows-vista.png b/public/images/os/windows-vista.png index cd2db79e..4a899a30 100644 Binary files a/public/images/os/windows-vista.png and b/public/images/os/windows-vista.png differ diff --git a/public/images/os/windows-xp.png b/public/images/os/windows-xp.png index cd2db79e..4a899a30 100644 Binary files a/public/images/os/windows-xp.png and b/public/images/os/windows-xp.png differ diff --git a/public/mstile-150x150.png b/public/mstile-150x150.png index 7f73bcd8..ae4fd79c 100644 Binary files a/public/mstile-150x150.png and b/public/mstile-150x150.png differ diff --git a/public/safari-pinned-tab.svg b/public/safari-pinned-tab.svg index 179f3e69..2d116eb8 100644 --- a/public/safari-pinned-tab.svg +++ b/public/safari-pinned-tab.svg @@ -1,75 +1 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/queries/admin/report.ts b/queries/admin/report.ts index ee7a0592..d2523f82 100644 --- a/queries/admin/report.ts +++ b/queries/admin/report.ts @@ -1,5 +1,7 @@ import { Prisma, Report } from '@prisma/client'; +import { REPORT_FILTER_TYPES } from 'lib/constants'; import prisma from 'lib/prisma'; +import { FilterResult, ReportSearchFilter, ReportSearchFilterType, SearchFilter } from 'lib/types'; export async function createReport(data: Prisma.ReportUncheckedCreateInput): Promise { return prisma.client.report.create({ data }); @@ -13,22 +15,6 @@ export async function getReportById(reportId: string): Promise { }); } -export async function getUserReports(userId: string): Promise { - return prisma.client.report.findMany({ - where: { - userId, - }, - }); -} - -export async function getWebsiteReports(websiteId: string): Promise { - return prisma.client.report.findMany({ - where: { - websiteId, - }, - }); -} - export async function updateReport( reportId: string, data: Prisma.ReportUpdateInput, @@ -39,3 +25,103 @@ export async function updateReport( export async function deleteReport(reportId: string): Promise { return prisma.client.report.delete({ where: { id: reportId } }); } + +export async function getReports( + ReportSearchFilter: ReportSearchFilter, +): Promise> { + 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, +): Promise> { + return getReports({ userId, ...filter }); +} + +export async function getReportsByWebsiteId( + websiteId: string, + filter: SearchFilter, +): Promise> { + return getReports({ websiteId, ...filter }); +} diff --git a/queries/admin/team.ts b/queries/admin/team.ts index a8b3385c..97838227 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -1,7 +1,8 @@ import { Prisma, Team } from '@prisma/client'; import prisma from 'lib/prisma'; -import { ROLES } from 'lib/constants'; +import { ROLES, TEAM_FILTER_TYPES } from 'lib/constants'; import { uuid } from 'lib/crypto'; +import { FilterResult, TeamSearchFilter, TeamSearchFilterType, SearchFilter } from 'lib/types'; export interface GetTeamOptions { includeTeamUser?: boolean; @@ -26,12 +27,6 @@ export function getTeamByAccessCode(accessCode: string, options: GetTeamOptions return getTeam({ accessCode }, options); } -export async function getTeams(where: Prisma.TeamWhereInput): Promise { - return prisma.client.team.findMany({ - where, - }); -} - export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise { const { id } = data; @@ -85,3 +80,82 @@ export async function deleteTeam( }), ]); } + +export async function getTeams( + TeamSearchFilter: TeamSearchFilter, + options?: { include?: Prisma.TeamInclude }, +): Promise> { + 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, +): Promise> { + return getTeams( + { userId, ...filter }, + { + include: { + teamUser: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + }, + }, + ); +} diff --git a/queries/admin/user.ts b/queries/admin/user.ts index f60c4801..f4be4751 100644 --- a/queries/admin/user.ts +++ b/queries/admin/user.ts @@ -1,9 +1,9 @@ -import { Prisma, Team, TeamUser } from '@prisma/client'; -import { getRandomChars } from 'next-basics'; +import { Prisma } from '@prisma/client'; import cache from 'lib/cache'; -import { ROLES } from 'lib/constants'; +import { ROLES, USER_FILTER_TYPES } from 'lib/constants'; 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 { includePassword?: boolean; @@ -36,125 +36,59 @@ export async function getUserByUsername(username: string, options: GetUserOption return getUser({ username }, options); } -export async function getUsers(): Promise { - return prisma.client.user.findMany({ - take: 100, - where: { - deletedAt: null, - }, - orderBy: [ - { - 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: { +export async function getUsers( + UserSearchFilter: UserSearchFilter = {}, + options?: { include?: Prisma.UserInclude }, +): Promise> { + const { teamId, filter, filterType = USER_FILTER_TYPES.all } = UserSearchFilter; + const where: Prisma.UserWhereInput = { + ...(teamId && { teamUser: { some: { - userId, + teamId, }, }, - }, - include: { - teamUser: { - include: { - user: { - select: { - id: true, - username: true, - }, + }), + ...(filter && { + AND: { + OR: [ + { + ...((filterType === USER_FILTER_TYPES.all || + filterType === USER_FILTER_TYPES.username) && { + username: { + startsWith: filter, + }, + }), }, - }, + ], }, - }, + }), + }; + const [pageFilters, getParameters] = prisma.getPageFilters({ + orderBy: 'username', + ...UserSearchFilter, }); -} -export async function getUserWebsites( - userId: string, - options?: { includeTeams: boolean }, -): Promise { - 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({ + const users = await prisma.client.user.findMany({ where: { - userId, + ...where, deletedAt: null, }, - orderBy: [ - { - name: 'asc', - }, - ], + ...pageFilters, + ...(options?.include && { include: options.include }), }); + 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: { diff --git a/queries/admin/website.ts b/queries/admin/website.ts index 35f32bac..68f634a6 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -1,6 +1,8 @@ import { Prisma, Website } from '@prisma/client'; import cache from 'lib/cache'; +import { ROLES, WEBSITE_FILTER_TYPES } from 'lib/constants'; import prisma from 'lib/prisma'; +import { FilterResult, WebsiteSearchFilter } from 'lib/types'; async function getWebsite(where: Prisma.WebsiteWhereUniqueInput): Promise { return prisma.client.website.findUnique({ @@ -16,11 +18,199 @@ export async function getWebsiteByShareId(shareId: string) { return getWebsite({ shareId }); } -export async function getWebsites(): Promise { - return prisma.client.website.findMany({ - orderBy: { - name: 'asc', +export async function getWebsites( + WebsiteSearchFilter: WebsiteSearchFilter, + options?: { include?: Prisma.WebsiteInclude }, +): Promise> { + 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> { + return getWebsites({ userId, ...filter }); +} + +export async function getWebsitesByTeamId( + teamId: string, + filter?: WebsiteSearchFilter, +): Promise> { + 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 { + 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', + }, + ], }); } diff --git a/queries/analytics/eventData/getEventDataFields.ts b/queries/analytics/eventData/getEventDataFields.ts index c61de517..f5f426e0 100644 --- a/queries/analytics/eventData/getEventDataFields.ts +++ b/queries/analytics/eventData/getEventDataFields.ts @@ -21,10 +21,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters & { fiel return rawQuery( ` select - event_key as fieldName, - data_type as dataType, - string_value as fieldValue, - count(*) as total + event_key as "fieldName", + data_type as "dataType", + string_value as "fieldValue", + count(*) as "total" from event_data where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts new file mode 100644 index 00000000..c34ba068 --- /dev/null +++ b/queries/analytics/reports/getRetention.ts @@ -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, + }, + ); +} diff --git a/queries/index.js b/queries/index.js index b9c0a34d..c3255795 100644 --- a/queries/index.js +++ b/queries/index.js @@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataFields'; export * from './analytics/eventData/getEventDataUsage'; export * from './analytics/events/saveEvent'; export * from './analytics/reports/getFunnel'; +export * from './analytics/reports/getRetention'; export * from './analytics/reports/getInsights'; export * from './analytics/pageviews/getPageviewMetrics'; export * from './analytics/pageviews/getPageviewStats';