diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js index 97eaa46c..e896b404 100644 --- a/components/layout/NavBar.js +++ b/components/layout/NavBar.js @@ -18,6 +18,8 @@ export function NavBar() { const links = [ { label: formatMessage(labels.dashboard), url: '/dashboard' }, + { label: formatMessage(labels.websites), url: '/websites' }, + { label: formatMessage(labels.reports), url: '/reports' }, !cloudMode && { label: formatMessage(labels.settings), url: '/settings' }, ].filter(n => n); diff --git a/components/messages.js b/components/messages.js index 6b4cbb51..afd8d848 100644 --- a/components/messages.js +++ b/components/messages.js @@ -21,6 +21,8 @@ export const labels = defineMessages({ details: { id: 'label.details', defaultMessage: 'Details' }, website: { id: 'label.website', defaultMessage: 'Website' }, websites: { id: 'label.websites', defaultMessage: 'Websites' }, + myWebsites: { id: 'label.my-websites', defaultMessage: 'My Websites' }, + teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team Websites' }, created: { id: 'label.created', defaultMessage: 'Created' }, edit: { id: 'label.edit', defaultMessage: 'Edit' }, name: { id: 'label.name', defaultMessage: 'Name' }, @@ -28,6 +30,7 @@ export const labels = defineMessages({ accessCode: { id: 'label.access-code', defaultMessage: 'Access code' }, teamId: { id: 'label.team-id', defaultMessage: 'Team ID' }, team: { id: 'label.team', defaultMessage: 'Team' }, + teamName: { id: 'label.team-name', defaultMessage: 'Team Name' }, regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' }, remove: { id: 'label.remove', defaultMessage: 'Remove' }, join: { id: 'label.join', defaultMessage: 'Join' }, @@ -242,16 +245,12 @@ export const messages = defineMessages({ }, noResultsFound: { id: 'message.no-results-found', - defaultMessage: 'No results were found.', + defaultMessage: 'No results found.', }, noWebsitesConfigured: { id: 'message.no-websites-configured', defaultMessage: 'You do not have any websites configured.', }, - noReportsConfigured: { - id: 'message.no-reports-configured', - defaultMessage: 'You do not have any reports configured.', - }, noTeamWebsites: { id: 'message.no-team-websites', defaultMessage: 'This team does not have any websites.', diff --git a/components/pages/reports/ReportsPage.js b/components/pages/reports/ReportsPage.js index 470e1b08..8fc56917 100644 --- a/components/pages/reports/ReportsPage.js +++ b/components/pages/reports/ReportsPage.js @@ -1,13 +1,29 @@ +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; +import { useMessages, useReports } from 'hooks'; import Link from 'next/link'; import { Button, Icon, Icons, Text } from 'react-basics'; -import { useMessages, useReports } from 'hooks'; import ReportsTable from './ReportsTable'; export function ReportsPage() { - const { formatMessage, labels } = useMessages(); - const { reports, error, isLoading } = useReports(); + const { formatMessage, labels, messages } = useMessages(); + const { + reports, + error, + isLoading, + deleteReport, + filter, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + } = useReports(); + + const hasData = (reports && reports?.data.length !== 0) || filter; + + const handleDelete = async id => { + await deleteReport(id); + }; return ( @@ -21,7 +37,23 @@ export function ReportsPage() { - + + {hasData && ( + + )} + {!hasData && ( + + )} ); } diff --git a/components/pages/reports/ReportsTable.js b/components/pages/reports/ReportsTable.js index 529f5359..98f5267a 100644 --- a/components/pages/reports/ReportsTable.js +++ b/components/pages/reports/ReportsTable.js @@ -1,9 +1,10 @@ -import { useState } from 'react'; -import { Flexbox, Icon, Icons, Text, Button, Modal } from 'react-basics'; +import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm'; import LinkButton from 'components/common/LinkButton'; import SettingsTable from 'components/common/SettingsTable'; -import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm'; import { useMessages } from 'hooks'; +import useUser from 'hooks/useUser'; +import { useState } from 'react'; +import { Button, Flexbox, Icon, Icons, Modal, Text } from 'react-basics'; export function ReportsTable({ data = [], @@ -12,14 +13,24 @@ export function ReportsTable({ onFilterChange, onPageChange, onPageSizeChange, + showDomain, }) { const [report, setReport] = useState(null); const { formatMessage, labels } = useMessages(); + const { user } = useUser(); + + const domainColumn = [ + { + name: 'domain', + label: formatMessage(labels.domain), + }, + ]; const columns = [ { name: 'name', label: formatMessage(labels.name) }, { name: 'description', label: formatMessage(labels.description) }, { name: 'type', label: formatMessage(labels.type) }, + ...(showDomain ? domainColumn : []), { name: 'action', label: ' ' }, ]; @@ -40,11 +51,15 @@ export function ReportsTable({ filterValue={filterValue} > {row => { - const { id } = row; + const { id, userId: reportOwnerId, website } = row; + if (showDomain) { + row.domain = website.domain; + } return ( {formatMessage(labels.view)} + {!showDomain || user.id === reportOwnerId || user.id === website?.userId} - + {(!showTeam || ownerId === user.id) && ( + + + + )} + + {close => } + + + )} + + ); + + return ( + + {addButton} + + {formatMessage(labels.myWebsites)} + {formatMessage(labels.teamWebsites)} + + + {tab === 'my-websites' && } + {tab === 'team-webaites' && ( + + )} + + ); +} + +export default WebsitesPage; diff --git a/hooks/index.js b/hooks/index.js index 004260b0..2596ba57 100644 --- a/hooks/index.js +++ b/hooks/index.js @@ -20,3 +20,4 @@ export * from './useTheme'; export * from './useTimezone'; export * from './useUser'; export * from './useWebsite'; +export * from './useWebsiteReports'; diff --git a/hooks/useReports.js b/hooks/useReports.js index 57d76492..932fa6dc 100644 --- a/hooks/useReports.js +++ b/hooks/useReports.js @@ -2,15 +2,15 @@ import { useState } from 'react'; import useApi from './useApi'; import useApiFilter from 'hooks/useApiFilter'; -export function useReports(websiteId) { +export function useReports() { const [modified, setModified] = useState(Date.now()); const { get, useQuery, del, useMutation } = useApi(); const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); 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 }), + ['reports', { modified, filter, page, pageSize }], + () => get(`/reports`, { filter, page, pageSize }), ); const deleteReport = id => { diff --git a/hooks/useWebsiteReports.js b/hooks/useWebsiteReports.js new file mode 100644 index 00000000..3b7ec415 --- /dev/null +++ b/hooks/useWebsiteReports.js @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import useApi from './useApi'; +import useApiFilter from 'hooks/useApiFilter'; + +export function useWebsiteReports(websiteId) { + const [modified, setModified] = useState(Date.now()); + const { get, useQuery, del, useMutation } = useApi(); + const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { data, error, isLoading } = useQuery( + ['reports:website', { websiteId, modified, filter, page, pageSize }], + () => get(`/websites/${websiteId}/reports`, { websiteId, filter, page, pageSize }), + ); + + const deleteReport = id => { + mutate(id, { + onSuccess: () => { + setModified(Date.now()); + }, + }); + }; + + return { + reports: data, + error, + isLoading, + deleteReport, + filter, + page, + pageSize, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + }; +} + +export default useWebsiteReports; diff --git a/lib/types.ts b/lib/types.ts index 5a25169a..65bef8fb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -27,6 +27,7 @@ export interface WebsiteSearchFilter extends SearchFilter { @@ -40,6 +41,7 @@ export interface TeamSearchFilter extends SearchFilter { export interface ReportSearchFilter extends SearchFilter { userId?: string; websiteId?: string; + includeTeams?: boolean; } export interface SearchFilter { diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts index 8c6825f1..db83e6ed 100644 --- a/pages/api/reports/index.ts +++ b/pages/api/reports/index.ts @@ -4,7 +4,7 @@ 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'; +import { createReport, getReportsByUserId, getReportsByWebsiteId } from 'queries'; export interface ReportsRequestQuery extends SearchFilter {} @@ -26,20 +26,14 @@ export default async ( await useCors(req, res); await useAuth(req, res); - const { websiteId } = req.query; - const { user: { id: userId }, } = req.auth; if (req.method === 'GET') { - if (!(websiteId && (await canViewWebsite(req.auth, websiteId)))) { - return unauthorized(res); - } - const { page, filter, pageSize } = req.query; - const data = await getReportsByWebsiteId(websiteId, { + const data = await getReportsByUserId(userId, { page, filter, pageSize: +pageSize || null, diff --git a/pages/api/users/[id]/websites.ts b/pages/api/users/[id]/websites.ts index 72d793d1..0e9231f7 100644 --- a/pages/api/users/[id]/websites.ts +++ b/pages/api/users/[id]/websites.ts @@ -21,7 +21,7 @@ export default async ( await useAuth(req, res); const { user } = req.auth; - const { id: userId, page, filter, pageSize, includeTeams } = req.query; + const { id: userId, page, filter, pageSize, includeTeams, onlyTeams } = req.query; if (req.method === 'GET') { if (!user.isAdmin && user.id !== userId) { @@ -33,6 +33,7 @@ export default async ( filter, pageSize: +pageSize || null, includeTeams, + onlyTeams, }); return ok(res, websites); diff --git a/pages/api/websites/[id]/reports.ts b/pages/api/websites/[id]/reports.ts new file mode 100644 index 00000000..60c6f714 --- /dev/null +++ b/pages/api/websites/[id]/reports.ts @@ -0,0 +1,38 @@ +import { canViewWebsite } from 'lib/auth'; +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 { getReportsByWebsiteId } from 'queries'; + +export interface ReportsRequestQuery extends SearchFilter { + id: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + const { id: websiteId } = req.query; + + if (req.method === 'GET') { + if (!(websiteId && (await canViewWebsite(req.auth, websiteId)))) { + return unauthorized(res); + } + + const { page, filter, pageSize } = req.query; + + const data = await getReportsByWebsiteId(websiteId, { + page, + filter, + pageSize: +pageSize || null, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/pages/reports/index.js b/pages/reports/index.js new file mode 100644 index 00000000..ff3b4e86 --- /dev/null +++ b/pages/reports/index.js @@ -0,0 +1,13 @@ +import AppLayout from 'components/layout/AppLayout'; +import ReportsPage from 'components/pages/reports/ReportsPage'; +import { useMessages } from 'hooks'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + + + + ); +} diff --git a/pages/websites/index.js b/pages/websites/index.js new file mode 100644 index 00000000..42a327bc --- /dev/null +++ b/pages/websites/index.js @@ -0,0 +1,13 @@ +import AppLayout from 'components/layout/AppLayout'; +import WebsitesPage from 'components/pages/websites/WebsitesPage'; +import useMessages from 'hooks/useMessages'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + + + + ); +} diff --git a/queries/admin/report.ts b/queries/admin/report.ts index d2523f82..3c3e3e2d 100644 --- a/queries/admin/report.ts +++ b/queries/admin/report.ts @@ -28,19 +28,52 @@ export async function deleteReport(reportId: string): Promise { export async function getReports( ReportSearchFilter: ReportSearchFilter, + options?: { include?: Prisma.ReportInclude }, ): Promise> { - const { userId, websiteId, filter, filterType = REPORT_FILTER_TYPES.all } = ReportSearchFilter; + const { + userId, + websiteId, + includeTeams, + filter, + filterType = REPORT_FILTER_TYPES.all, + } = ReportSearchFilter; + const where: Prisma.ReportWhereInput = { ...(userId && { userId: userId }), ...(websiteId && { websiteId: websiteId }), - ...(filter && { - AND: { + AND: [ + { + OR: [ + { + ...(userId && { userId: userId }), + }, + { + ...(includeTeams && { + website: { + teamWebsite: { + some: { + team: { + teamUser: { + some: { + userId, + }, + }, + }, + }, + }, + }, + }), + }, + ], + }, + { OR: [ { ...((filterType === REPORT_FILTER_TYPES.all || filterType === REPORT_FILTER_TYPES.name) && { name: { startsWith: filter, + mode: 'insensitive', }, }), }, @@ -49,6 +82,7 @@ export async function getReports( filterType === REPORT_FILTER_TYPES.description) && { description: { startsWith: filter, + mode: 'insensitive', }, }), }, @@ -57,6 +91,7 @@ export async function getReports( filterType === REPORT_FILTER_TYPES.type) && { type: { startsWith: filter, + mode: 'insensitive', }, }), }, @@ -66,6 +101,7 @@ export async function getReports( user: { username: { startsWith: filter, + mode: 'insensitive', }, }, }), @@ -76,6 +112,7 @@ export async function getReports( website: { name: { startsWith: filter, + mode: 'insensitive', }, }, }), @@ -86,13 +123,14 @@ export async function getReports( website: { domain: { startsWith: filter, + mode: 'insensitive', }, }, }), }, ], }, - }), + ], }; const [pageFilters, getParameters] = prisma.getPageFilters(ReportSearchFilter); @@ -100,6 +138,7 @@ export async function getReports( const reports = await prisma.client.report.findMany({ where, ...pageFilters, + ...(options?.include && { include: options.include }), }); const count = await prisma.client.report.count({ where, @@ -116,7 +155,19 @@ export async function getReportsByUserId( userId: string, filter: SearchFilter, ): Promise> { - return getReports({ userId, ...filter }); + return getReports( + { userId, ...filter }, + { + include: { + website: { + select: { + domain: true, + userId: true, + }, + }, + }, + }, + ); } export async function getReportsByWebsiteId( diff --git a/queries/admin/team.ts b/queries/admin/team.ts index 3294c029..71ea634a 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -97,7 +97,7 @@ export async function getTeams( OR: [ { ...((filterType === TEAM_FILTER_TYPES.all || filterType === TEAM_FILTER_TYPES.name) && { - name: { startsWith: filter }, + name: { startsWith: filter, mode: 'insensitive' }, }), }, { @@ -109,6 +109,7 @@ export async function getTeams( user: { username: { startsWith: filter, + mode: 'insensitive', }, }, }, diff --git a/queries/admin/user.ts b/queries/admin/user.ts index f4be4751..3aece6d1 100644 --- a/queries/admin/user.ts +++ b/queries/admin/user.ts @@ -57,6 +57,7 @@ export async function getUsers( filterType === USER_FILTER_TYPES.username) && { username: { startsWith: filter, + mode: 'insensitive', }, }), }, diff --git a/queries/admin/website.ts b/queries/admin/website.ts index 721b0662..8c3535cc 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -26,29 +26,11 @@ export async function getWebsites( userId, teamId, includeTeams, + onlyTeams, 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: { @@ -61,28 +43,53 @@ export async function getWebsites( { OR: [ { - ...(userId && { - userId, - }), + ...(userId && + !onlyTeams && { + userId, + }), }, { - ...(includeTeams && { - teamWebsite: { - some: { - team: { - teamUser: { - some: { - userId, + ...((includeTeams || onlyTeams) && { + AND: [ + { + teamWebsite: { + some: { + team: { + teamUser: { + some: { + userId, + }, + }, }, }, }, }, - }, + { + userId: { + not: userId, + }, + }, + ], + }), + }, + ], + }, + { + OR: [ + { + ...((filterType === WEBSITE_FILTER_TYPES.all || + filterType === WEBSITE_FILTER_TYPES.name) && { + name: { startsWith: filter, mode: 'insensitive' }, + }), + }, + { + ...((filterType === WEBSITE_FILTER_TYPES.all || + filterType === WEBSITE_FILTER_TYPES.domain) && { + domain: { startsWith: filter, mode: 'insensitive' }, }), }, ], }, - { ...(filter && filterQuery) }, ], }; @@ -108,7 +115,28 @@ export async function getWebsitesByUserId( userId: string, filter?: WebsiteSearchFilter, ): Promise> { - return getWebsites({ userId, ...filter }); + return getWebsites( + { userId, ...filter }, + { + include: { + teamWebsite: { + include: { + team: { + select: { + name: true, + }, + }, + }, + }, + user: { + select: { + username: true, + id: true, + }, + }, + }, + }, + ); } export async function getWebsitesByTeamId( diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts index 9b18df49..ee7e4619 100644 --- a/queries/analytics/reports/getRetention.ts +++ b/queries/analytics/reports/getRetention.ts @@ -25,7 +25,7 @@ async function relationalQuery( }, ): Promise< { - date: Date; + date: string; day: number; visitors: number; returnVisitors: number; @@ -33,13 +33,15 @@ async function relationalQuery( }[] > { const { startDate, endDate } = dateRange; - const { rawQuery } = prisma; + const { getDateQuery, rawQuery } = prisma; + const timezone = 'utc'; + const unit = 'day'; return rawQuery( ` WITH cohort_items AS ( select session_id, - date_trunc('day', created_at)::date as cohort_date + ${getDateQuery('created_at', unit, timezone)} as cohort_date from session where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} @@ -47,7 +49,7 @@ async function relationalQuery( user_activities AS ( select distinct w.session_id, - (date_trunc('day', w.created_at)::date - c.cohort_date::date) as day_number + (${getDateQuery('created_at', unit, timezone)}::date - c.cohort_date::date) as day_number from website_event w join cohort_items c on w.session_id = c.session_id @@ -98,7 +100,7 @@ async function clickhouseQuery( }, ): Promise< { - date: Date; + date: string; day: number; visitors: number; returnVisitors: number; @@ -106,13 +108,15 @@ async function clickhouseQuery( }[] > { const { startDate, endDate } = dateRange; - const { rawQuery } = clickhouse; + const { getDateQuery, getDateStringQuery, rawQuery } = clickhouse; + const timezone = 'UTC'; + const unit = 'day'; return rawQuery( ` WITH cohort_items AS ( select - min(date_trunc('day', created_at)) as cohort_date, + min(${getDateQuery('created_at', unit, timezone)}) as cohort_date, session_id from website_event where website_id = {websiteId:UUID} @@ -122,7 +126,7 @@ async function clickhouseQuery( user_activities AS ( select distinct w.session_id, - (date_trunc('day', w.created_at) - c.cohort_date) / 86400 as day_number + (${getDateQuery('created_at', unit, timezone)} - c.cohort_date) / 86400 as day_number from website_event w join cohort_items c on w.session_id = c.session_id @@ -147,7 +151,7 @@ async function clickhouseQuery( group by 1, 2 ) select - c.cohort_date as date, + ${getDateStringQuery('c.cohort_date', unit)} as date, c.day_number as day, s.visitors as visitors, c.visitors returnVisitors,