From ab5b1bf3547f729678ef94fa9dd911a77857b732 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 29 Jun 2026 18:17:57 -0700 Subject: [PATCH] Board component, security, test updates --- src/app/(main)/boards/BoardEditForm.tsx | 25 +- .../boards/[boardId]/BoardComponentSelect.tsx | 224 ++++++++++++++---- .../(main)/boards/boardComponentRegistry.tsx | 222 ++++++++++++++++- .../(reports)/funnels/BoardFunnel.tsx | 41 ++++ .../[websiteId]/(reports)/funnels/Funnel.tsx | 12 +- .../[websiteId]/(reports)/goals/BoardGoal.tsx | 45 ++++ .../[websiteId]/(reports)/goals/Goal.tsx | 14 +- .../(reports)/revenue/BoardRevenueChart.tsx | 34 +++ .../revenue/BoardRevenueMetricsBar.tsx | 27 +++ .../revenue/BoardRevenueMetricsTable.tsx | 53 +++++ .../revenue/useBoardRevenueCurrency.ts | 6 + .../[websiteId]/(reports)/utm/BoardUTM.tsx | 112 +++++++++ .../[websiteId]/events/EventsMetricsBar.tsx | 88 ++++--- .../[websiteId]/events/EventsPage.tsx | 68 +----- .../realtime/BoardRealtimeActiveUsers.tsx | 25 ++ .../realtime/BoardRealtimeChart.tsx | 13 + .../realtime/BoardRealtimeMetricsBar.tsx | 13 + src/app/api/reports/[reportId]/route.ts | 4 + src/app/api/reports/route.ts | 22 +- src/lib/boardComponentCompatibility.ts | 11 + src/lib/boards.test.ts | 88 +++++++ src/permissions/report.test.ts | 125 ++++++++++ src/permissions/report.ts | 36 ++- 23 files changed, 1128 insertions(+), 180 deletions(-) create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeActiveUsers.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx create mode 100644 src/permissions/report.test.ts diff --git a/src/app/(main)/boards/BoardEditForm.tsx b/src/app/(main)/boards/BoardEditForm.tsx index ec405ff35..0f5ded174 100644 --- a/src/app/(main)/boards/BoardEditForm.tsx +++ b/src/app/(main)/boards/BoardEditForm.tsx @@ -8,6 +8,7 @@ import { Loading, Row, Select, + Text, TextField, } from '@umami/react-zen'; import { useBoardQuery, useMessages, useNavigation, useUpdateQuery } from '@/components/hooks'; @@ -97,6 +98,14 @@ export function BoardEditForm({ : type === BOARD_TYPES.link ? t(labels.link) : t(labels.website); + const boardTypeLabel = + type === BOARD_TYPES.mixed + ? t(labels.open) + : type === BOARD_TYPES.pixel + ? t(labels.pixel) + : type === BOARD_TYPES.link + ? t(labels.link) + : t(labels.website); const handleTypeChange = (value: string) => { setValue('type', value as BoardType, { shouldDirty: true }); @@ -126,12 +135,16 @@ export function BoardEditForm({ rules={{ required: t(labels.required) }} > - + {boardId ? ( + {boardTypeLabel} + ) : ( + + )} {requiresBoardEntity(type) && ( diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx index 0e01eee5a..51cd24fb6 100644 --- a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx +++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx @@ -9,7 +9,7 @@ import { TextField, } from '@umami/react-zen'; import { useEffect, useMemo, useState } from 'react'; -import { useMessages } from '@/components/hooks'; +import { useApi, useMessages } from '@/components/hooks'; import { LinkSelect } from '@/components/input/LinkSelect'; import { PixelSelect } from '@/components/input/PixelSelect'; import { WebsiteSelect } from '@/components/input/WebsiteSelect'; @@ -29,6 +29,16 @@ import { } from '../boardComponentRegistry'; import { BoardComponentRenderer } from './BoardComponentRenderer'; +const COMPONENT_GROUP_ORDER: string[] = [ + 'Traffic', + 'Events', + 'Behavior', + 'Realtime', + 'Growth', + 'Revenue', + 'Content', +]; + export function BoardComponentSelect({ teamId, boardType, @@ -47,6 +57,7 @@ export function BoardComponentSelect({ onClose: () => void; }) { const { t, labels, messages } = useMessages(); + const { get, useQuery } = useApi(); const initialEntity = getComponentEntity(initialConfig); const [selectedDef, setSelectedDef] = useState(null); const [configValues, setConfigValues] = useState>({}); @@ -57,16 +68,17 @@ export function BoardComponentSelect({ const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); - const allDefinitions = useMemo( - () => getComponentDefinitions().toSorted((a, b) => a.name.localeCompare(b.name)), - [], - ); + const allDefinitions = useMemo(() => getComponentDefinitions(), []); const activeEntityType = isOpenBoardType(boardType) ? selectedEntityType : boardEntityType; const isSelectedDefSupported = selectedDef ? isBoardComponentSupported(selectedDef.type, activeEntityType) : false; - const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => { + const getDefaultConfigValues = ( + def: ComponentDefinition, + config?: BoardComponentConfig, + entityType?: BoardEntityType, + ) => { const defaults: Record = {}; for (const field of def.configFields ?? []) { @@ -77,6 +89,19 @@ export function BoardComponentSelect({ Object.assign(defaults, def.defaultProps); } + for (const field of def.configFields ?? []) { + if (field.type !== 'select') { + continue; + } + + const options = (entityType && field.optionsByEntityType?.[entityType]) ?? field.options; + const currentValue = defaults[field.name]; + + if (options?.length && !options.some(option => String(option.value) === String(currentValue))) { + defaults[field.name] = options[0].value; + } + } + if (config?.props) { Object.assign(defaults, config.props); } @@ -96,10 +121,9 @@ export function BoardComponentSelect({ } setSelectedDef(definition); - setConfigValues(getDefaultConfigValues(definition, initialConfig)); - setSelectedEntityType( - initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website, - ); + const entityType = initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website; + setConfigValues(getDefaultConfigValues(definition, initialConfig, entityType)); + setSelectedEntityType(entityType); setSelectedEntityId(initialEntity.entityId || boardEntityId); setTitle(initialConfig.title ?? definition.name); setDescription(initialConfig.description || ''); @@ -114,7 +138,7 @@ export function BoardComponentSelect({ const handleSelectComponent = (def: ComponentDefinition) => { setSelectedDef(def); - setConfigValues(getDefaultConfigValues(def)); + setConfigValues(getDefaultConfigValues(def, undefined, activeEntityType)); setTitle(def.name); setDescription(''); }; @@ -136,10 +160,55 @@ export function BoardComponentSelect({ ? selectedEntityId : boardEntityId : undefined; + const reportFields = selectedDef?.configFields?.filter( + field => field.type === 'report' && field.reportType, + ); + const { data: reportOptionsData, isLoading: isLoadingReportOptions } = useQuery({ + queryKey: [ + 'board-component-report-options', + { + websiteId: resolvedEntityId, + reportTypes: reportFields?.map(field => field.reportType).join(','), + }, + ], + queryFn: async () => { + const types = [...new Set(reportFields?.map(field => field.reportType).filter(Boolean))]; + + const entries = await Promise.all( + types.map(async type => { + const response = await get('/reports', { + websiteId: resolvedEntityId, + type, + pageSize: 1000, + }); + + return [type, response.data] as const; + }), + ); + + return Object.fromEntries(entries); + }, + enabled: !!resolvedEntityId && !!reportFields?.length, + }); + const reportOptionsByType = reportOptionsData ?? {}; + const hasRequiredConfig = selectedDef + ? (selectedDef.configFields ?? []).every(field => { + if (!field.required) { + return true; + } + + const value = configValues[field.name]; + return value !== undefined && value !== null && value !== ''; + }) + : false; const handleEntityTypeChange = (value: string) => { setSelectedEntityType(value as BoardEntityType); setSelectedEntityId(undefined); + setSelectedDef(null); + setConfigValues({}); + setTitle(''); + setDescription(''); }; const handleAdd = () => { @@ -186,7 +255,11 @@ export function BoardComponentSelect({ [selectedDef, configValues], ); - const canSave = !!selectedDef && isSelectedDefSupported && (!needsWebsite || !!resolvedEntityId); + const canSave = + !!selectedDef && + isSelectedDefSupported && + hasRequiredConfig && + (!needsWebsite || !!resolvedEntityId); const availableDefinitions = useMemo( () => allDefinitions.filter( @@ -195,6 +268,33 @@ export function BoardComponentSelect({ ), [activeEntityType, allDefinitions, selectedDef?.type], ); + const groupedDefinitions = useMemo(() => { + const order = new Map(COMPONENT_GROUP_ORDER.map((group, index) => [group, index])); + const groups = new Map(); + + for (const def of availableDefinitions) { + const group = def.group ?? 'Other'; + const items = groups.get(group) ?? []; + items.push(def); + groups.set(group, items); + } + + return Array.from(groups.entries()) + .sort(([a], [b]) => { + const left = order.get(a) ?? Number.MAX_SAFE_INTEGER; + const right = order.get(b) ?? Number.MAX_SAFE_INTEGER; + + if (left !== right) { + return left - right; + } + + return a.localeCompare(b); + }) + .map(([group, definitions]) => ({ + group, + definitions, + })); + }, [availableDefinitions]); return ( @@ -310,6 +410,35 @@ export function BoardComponentSelect({ style={{ minHeight: 200 }} /> )} + + {field.type === 'report' && ( + <> + + {isLoadingReportOptions && ( + + Loading options... + + )} + {!isLoadingReportOptions && + resolvedEntityId && + (reportOptionsByType[field.reportType ?? '']?.length ?? 0) === 0 && ( + + No saved items found. + + )} + + )} ))} @@ -321,40 +450,47 @@ export function BoardComponentSelect({ {hasSelectedEntity ? ( - {availableDefinitions.map(def => { - const Icon = def.icon; + {groupedDefinitions.map(({ group, definitions }) => ( + + + {group} + + {definitions.map(def => { + const Icon = def.icon; - return ( - - handleSelectComponent(def)} - > - - - + handleSelectComponent(def)} > - {def.name} - - - {def.description} - - - - - ); - })} + + + + {def.name} + + + {def.description} + + + + + ); + })} + + ))} ) : ( diff --git a/src/app/(main)/boards/boardComponentRegistry.tsx b/src/app/(main)/boards/boardComponentRegistry.tsx index 3607b733a..1af813051 100644 --- a/src/app/(main)/boards/boardComponentRegistry.tsx +++ b/src/app/(main)/boards/boardComponentRegistry.tsx @@ -1,30 +1,47 @@ import type { ComponentType } from 'react'; import { TextBlock } from '@/app/(main)/boards/TextBlock'; +import { BoardFunnel } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel'; import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar'; import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar'; +import { BoardGoal } from '@/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal'; +import { BoardRevenueChart } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart'; +import { BoardRevenueMetricsBar } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar'; +import { BoardRevenueMetricsTable } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable'; +import { BoardUTM } from '@/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM'; +import { EventsMetricsBar } from '@/app/(main)/websites/[websiteId]/events/EventsMetricsBar'; +import { BoardRealtimeActiveUsers } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeActiveUsers'; +import { BoardRealtimeChart } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart'; +import { BoardRealtimeMetricsBar } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar'; import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar'; import { + Activity, Calendar, ChartColumnBig, ChartPie, FileText, + GitBranch, Globe, PanelTop, Sheet, + Target, + Users, } from '@/components/icons'; import { EventsChart } from '@/components/metrics/EventsChart'; import { MetricsTable } from '@/components/metrics/MetricsTable'; import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic'; import { WorldMap } from '@/components/metrics/WorldMap'; +import { CURRENCIES, DEFAULT_CURRENCY } from '@/lib/constants'; export interface ConfigField { name: string; label: string; - type: 'select' | 'number' | 'text' | 'textarea'; + type: 'select' | 'number' | 'text' | 'textarea' | 'report'; options?: { label: string; value: string }[]; optionsByEntityType?: Record; defaultValue?: any; + reportType?: string; + required?: boolean; } export interface ComponentDefinition { @@ -32,6 +49,7 @@ export interface ComponentDefinition { name: string; description: string; category: string; + group?: string; icon: ComponentType; component: ComponentType; componentByEntityType?: Record>; @@ -76,18 +94,13 @@ const METRIC_TYPES = [ const PIXEL_LINK_METRIC_TYPES = METRIC_TYPES.filter(({ value }) => [ 'referrer', - 'country', - 'region', - 'city', + 'channel', 'browser', 'os', 'device', - 'query', - 'utmSource', - 'utmMedium', - 'utmCampaign', - 'utmContent', - 'utmTerm', + 'country', + 'region', + 'city', ].includes(value), ); @@ -97,6 +110,26 @@ const LIMIT_OPTIONS = [ { label: '20', value: '20' }, ]; +const UTM_PARAM_OPTIONS = [ + { label: 'Source', value: 'utm_source' }, + { label: 'Medium', value: 'utm_medium' }, + { label: 'Campaign', value: 'utm_campaign' }, + { label: 'Content', value: 'utm_content' }, + { label: 'Term', value: 'utm_term' }, +]; + +const REVENUE_METRIC_TYPE_OPTIONS = [ + { label: 'Referrers', value: 'referrer' }, + { label: 'Channels', value: 'channel' }, + { label: 'Countries', value: 'country' }, + { label: 'Regions', value: 'region' }, +]; + +const CURRENCY_OPTIONS = CURRENCIES.map(({ id, name }) => ({ + label: `${id} - ${name}`, + value: id, +})); + const PixelMetricsBarAdapter = ({ websiteId }: { websiteId?: string }) => websiteId ? : null; @@ -110,6 +143,7 @@ const componentDefinitions: ComponentDefinition[] = [ name: 'Metrics bar', description: 'Key metrics: views, visitors, bounces, time on site', category: 'overview', + group: 'Traffic', icon: PanelTop, component: WebsiteMetricsBar, componentByEntityType: { @@ -117,14 +151,177 @@ const componentDefinitions: ComponentDefinition[] = [ link: LinkMetricsBarAdapter, }, }, + { + type: 'EventsMetricsBar', + name: 'Event metrics bar', + description: 'Key metrics: visitors, visits, events, unique events', + category: 'overview', + group: 'Events', + icon: PanelTop, + component: EventsMetricsBar, + }, + { + type: 'Goal', + name: 'Goal', + description: 'Conversion progress for a saved goal', + category: 'overview', + group: 'Behavior', + icon: Target, + component: BoardGoal, + configFields: [ + { + name: 'reportId', + label: 'Saved goal', + type: 'report', + reportType: 'goal', + required: true, + }, + ], + }, + { + type: 'Funnel', + name: 'Funnel', + description: 'Step conversion for a saved funnel', + category: 'overview', + group: 'Behavior', + icon: GitBranch, + component: BoardFunnel, + configFields: [ + { + name: 'reportId', + label: 'Saved funnel', + type: 'report', + reportType: 'funnel', + required: true, + }, + ], + }, { type: 'WebsiteChart', name: 'Visitors chart', description: 'Page views and visitors over time', category: 'overview', + group: 'Traffic', icon: ChartColumnBig, component: WebsiteChart, }, + { + type: 'UTM', + name: 'UTM', + description: 'List and pie chart for one UTM parameter', + category: 'visualization', + group: 'Growth', + icon: ChartPie, + component: BoardUTM, + defaultProps: { param: 'utm_source', limit: 10 }, + configFields: [ + { + name: 'param', + label: 'UTM param', + type: 'select', + options: UTM_PARAM_OPTIONS, + defaultValue: 'utm_source', + }, + { + name: 'limit', + label: 'Rows', + type: 'select', + options: LIMIT_OPTIONS, + defaultValue: '10', + }, + ], + }, + { + type: 'RealtimeHeader', + name: 'Realtime metrics bar', + description: 'Live views, visitors, events, and countries', + category: 'overview', + group: 'Realtime', + icon: PanelTop, + component: BoardRealtimeMetricsBar, + }, + { + type: 'RealtimeChart', + name: 'Realtime chart', + description: 'Live visitors and page views by minute', + category: 'visualization', + group: 'Realtime', + icon: Activity, + component: BoardRealtimeChart, + }, + { + type: 'RealtimeActiveUsers', + name: 'Active users', + description: 'Current visitors online', + category: 'overview', + group: 'Realtime', + icon: Users, + component: BoardRealtimeActiveUsers, + }, + { + type: 'RevenueMetricsBar', + name: 'Revenue metrics bar', + description: 'Revenue summary: total, AOV, ARPU, orders, and customers', + category: 'overview', + group: 'Revenue', + icon: PanelTop, + component: BoardRevenueMetricsBar, + defaultProps: { currency: DEFAULT_CURRENCY }, + configFields: [ + { + name: 'currency', + label: 'Currency', + type: 'select', + options: CURRENCY_OPTIONS, + defaultValue: DEFAULT_CURRENCY, + }, + ], + }, + { + type: 'RevenueChart', + name: 'Revenue chart', + description: 'Revenue over time', + category: 'visualization', + group: 'Revenue', + icon: ChartColumnBig, + component: BoardRevenueChart, + defaultProps: { currency: DEFAULT_CURRENCY }, + configFields: [ + { + name: 'currency', + label: 'Currency', + type: 'select', + options: CURRENCY_OPTIONS, + defaultValue: DEFAULT_CURRENCY, + }, + ], + }, + { + type: 'RevenueMetricsTable', + name: 'Revenue metrics table', + description: 'Revenue breakdown by source or location', + category: 'tables', + group: 'Revenue', + icon: Sheet, + component: BoardRevenueMetricsTable, + defaultProps: { type: 'referrer', currency: DEFAULT_CURRENCY }, + configFields: [ + { + name: 'type', + label: 'Metric type', + type: 'select', + options: REVENUE_METRIC_TYPE_OPTIONS, + defaultValue: 'referrer', + }, + { + name: 'currency', + label: 'Currency', + type: 'select', + options: CURRENCY_OPTIONS, + defaultValue: DEFAULT_CURRENCY, + }, + ], + }, // Tables { @@ -132,6 +329,7 @@ const componentDefinitions: ComponentDefinition[] = [ name: 'Metrics table', description: 'Table of metrics by dimension', category: 'tables', + group: 'Traffic', icon: Sheet, component: MetricsTable, defaultProps: { type: 'path', limit: 10 }, @@ -163,6 +361,7 @@ const componentDefinitions: ComponentDefinition[] = [ name: 'World map', description: 'Geographic distribution of visitors', category: 'visualization', + group: 'Traffic', icon: Globe, component: WorldMap, }, @@ -171,6 +370,7 @@ const componentDefinitions: ComponentDefinition[] = [ name: 'Weekly traffic', description: 'Traffic heatmap by day and hour', category: 'visualization', + group: 'Traffic', icon: Calendar, component: WeeklyTraffic, }, @@ -179,6 +379,7 @@ const componentDefinitions: ComponentDefinition[] = [ name: 'Events chart', description: 'Custom events over time', category: 'visualization', + group: 'Events', icon: ChartPie, component: EventsChart, }, @@ -189,6 +390,7 @@ const componentDefinitions: ComponentDefinition[] = [ name: 'Text', description: 'Free-form text content', category: 'content', + group: 'Content', icon: FileText, component: TextBlock, requiresWebsite: false, diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx new file mode 100644 index 000000000..8818cf381 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx @@ -0,0 +1,41 @@ +import { Text } from '@umami/react-zen'; +import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useReportQuery } from '@/components/hooks/queries/useReportQuery'; +import { Funnel } from './Funnel'; + +export function BoardFunnel({ websiteId, reportId }: { websiteId: string; reportId?: string }) { + const { data, isLoading, error, isFetching } = useReportQuery(reportId || ''); + + if (!reportId) { + return ( + + ); + } + + if (data && (data.type !== 'funnel' || data.websiteId !== websiteId)) { + return ( + + ); + } + + return ( + + {data ? ( + + ) : ( + Funnel unavailable + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx index 2525195fd..01015605c 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -15,9 +15,17 @@ interface FunnelProps { type: string; parameters: Record; websiteId: string; + allowEdit?: boolean; } -export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) { +export function Funnel({ + id, + name, + type, + parameters, + websiteId, + allowEdit = true, +}: FunnelProps) { const { t, labels } = useMessages(); const { pathname } = useNavigation(); const isSharePage = pathname.includes('/share/'); @@ -39,7 +47,7 @@ export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) { - {!isSharePage && ( + {allowEdit && !isSharePage && ( ; + } + + if (data && (data.type !== 'goal' || data.websiteId !== websiteId)) { + return ( + + ); + } + + return ( + + {data ? ( + + ) : ( + Goal unavailable + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx index 5ed1848e9..6983ab4cc 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx @@ -19,11 +19,21 @@ export interface GoalProps { websiteId: string; startDate: Date; endDate: Date; + allowEdit?: boolean; } export type GoalData = { num: number; total: number }; -export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { +export function Goal({ + id, + name, + type, + parameters, + websiteId, + startDate, + endDate, + allowEdit = true, +}: GoalProps) { const { t, labels } = useMessages(); const { pathname } = useNavigation(); const isSharePage = pathname.includes('/share/'); @@ -47,7 +57,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate - {!isSharePage && ( + {allowEdit && !isSharePage && ( + {data && ( + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx new file mode 100644 index 000000000..e10d79cde --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar.tsx @@ -0,0 +1,27 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange } from '@/components/hooks'; +import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery'; +import { RevenueMetricsBar } from './RevenueMetricsBar'; +import { useBoardRevenueCurrency } from './useBoardRevenueCurrency'; + +export function BoardRevenueMetricsBar({ + websiteId, + currency: selectedCurrency, +}: { + websiteId: string; + currency?: string; +}) { + const currency = useBoardRevenueCurrency(selectedCurrency); + const { compare } = useDateRange(); + const { data, isLoading, isFetching, error } = useRevenueStatsQuery({ + websiteId, + currency, + compare, + }); + + return ( + + {data && } + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx new file mode 100644 index 000000000..20a95c191 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable.tsx @@ -0,0 +1,53 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import type { RevenueMetricType } from '@/components/hooks/queries/useRevenueMetricsQuery'; +import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery'; +import { RevenueMetricsTable } from './RevenueMetricsTable'; +import { useBoardRevenueCurrency } from './useBoardRevenueCurrency'; + +function getRevenueTableTitle(type: RevenueMetricType, labels: Record, t: (key: string) => string) { + switch (type) { + case 'referrer': + return t(labels.referrer); + case 'channel': + return t(labels.channel); + case 'country': + return t(labels.country); + case 'region': + return t(labels.region); + } +} + +export function BoardRevenueMetricsTable({ + websiteId, + type = 'referrer', + currency: selectedCurrency, +}: { + websiteId: string; + type?: RevenueMetricType; + currency?: string; +}) { + const currency = useBoardRevenueCurrency(selectedCurrency); + const { compare } = useDateRange(); + const { t, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useRevenueStatsQuery({ + websiteId, + currency, + compare, + }); + + return ( + + {data && ( + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts b/src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts new file mode 100644 index 000000000..a4a50c4f7 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/useBoardRevenueCurrency.ts @@ -0,0 +1,6 @@ +import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants'; +import { getItem } from '@/lib/storage'; + +export function useBoardRevenueCurrency(currency?: string) { + return currency || getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY; +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx new file mode 100644 index 000000000..5444e5e71 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx @@ -0,0 +1,112 @@ +import { Column, Heading, Text } from '@umami/react-zen'; +import { useEffect, useRef, useState } from 'react'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useMessages, useResultQuery } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS } from '@/lib/constants'; + +const STACKED_LAYOUT_WIDTH = 900; + +export function BoardUTM({ + websiteId, + param = 'utm_source', + limit = 10, +}: { + websiteId: string; + param?: string; + limit?: number | string; +}) { + const wrapperRef = useRef(null); + const [isStacked, setIsStacked] = useState(false); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const { t, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery('utm', { + websiteId, + startDate, + endDate, + }); + + const itemLimit = Number(limit) || 10; + const items = (data?.[param] ?? []).slice(0, itemLimit); + const total = items.reduce((sum, { views }) => { + return +sum + +views; + }, 0); + const chartData = { + labels: items.map(({ utm }) => utm), + datasets: [ + { + data: items.map(({ views }) => views), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + + useEffect(() => { + const updateLayout = () => { + const width = wrapperRef.current?.clientWidth ?? 0; + + setIsStacked(current => { + const next = width > 0 && width < STACKED_LAYOUT_WIDTH; + return current === next ? current : next; + }); + }; + + updateLayout(); + + const resizeObserver = + typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateLayout) : null; + + if (wrapperRef.current && resizeObserver) { + resizeObserver.observe(wrapperRef.current); + } + + window.addEventListener('resize', updateLayout); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateLayout); + }; + }, []); + + return ( + +
+
+ + + {param.replace(/^utm_/, '')} + + ({ + label: utm, + count: views, + percent: total ? (views / total) * 100 : 0, + }))} + /> + + + + +
+
+
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx index ff5bd74f1..d6b4cf566 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx @@ -1,40 +1,70 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages } from '@/components/hooks'; -import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery'; import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricsBar } from '@/components/metrics/MetricsBar'; import { formatLongNumber } from '@/lib/format'; export function EventsMetricsBar({ websiteId }: { websiteId: string }) { - const { t, labels } = useMessages(); - const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); + const { isAllTime } = useDateRange(); + const { t, labels, getErrorMessage } = useMessages(); + const { data, isLoading, isFetching, error } = useEventStatsQuery({ + websiteId, + }); + + const { events, visitors, visits, uniqueEvents, comparison } = data || {}; + + const metrics = data + ? [ + { + value: visitors, + label: t(labels.visitors), + change: visitors - comparison.visitors, + formatValue: formatLongNumber, + }, + { + value: visits, + label: t(labels.visits), + change: visits - comparison.visits, + formatValue: formatLongNumber, + }, + { + value: events, + label: t(labels.events), + change: events - comparison.events, + formatValue: formatLongNumber, + }, + { + value: uniqueEvents, + label: t(labels.uniqueEvents), + change: uniqueEvents - comparison.uniqueEvents, + formatValue: formatLongNumber, + }, + ] + : null; return ( - - {data && ( - - - - - - - )} + + + {metrics?.map(({ label, value, change, formatValue }) => { + return ( + + ); + })} + ); } diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx index f359b9cb0..2f653665d 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -3,90 +3,30 @@ import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { type Key, useState } from 'react'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; -import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; -import { useDateRange, useMessages } from '@/components/hooks'; -import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery'; +import { useMessages } from '@/components/hooks'; import { EventsChart } from '@/components/metrics/EventsChart'; -import { MetricCard } from '@/components/metrics/MetricCard'; -import { MetricsBar } from '@/components/metrics/MetricsBar'; import { MetricsTable } from '@/components/metrics/MetricsTable'; -import { formatLongNumber } from '@/lib/format'; import { getItem, setItem } from '@/lib/storage'; import { EventProperties } from './EventProperties'; import { EventsDataTable } from './EventsDataTable'; +import { EventsMetricsBar } from './EventsMetricsBar'; const KEY_NAME = 'umami.events.tab'; export function EventsPage({ websiteId }) { const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); - const { isAllTime } = useDateRange(); - const { t, labels, getErrorMessage } = useMessages(); - const { data, isLoading, isFetching, error } = useEventStatsQuery({ - websiteId, - }); + const { t, labels } = useMessages(); const handleSelect = (value: Key) => { setItem(KEY_NAME, value); setTab(value); }; - const { events, visitors, visits, uniqueEvents, comparison } = data || {}; - - const metrics = data - ? [ - { - value: visitors, - label: t(labels.visitors), - change: visitors - comparison.visitors, - formatValue: formatLongNumber, - }, - { - value: visits, - label: t(labels.visits), - change: visits - comparison.visits, - formatValue: formatLongNumber, - }, - { - value: events, - label: t(labels.events), - change: events - comparison.events, - formatValue: formatLongNumber, - }, - { - value: uniqueEvents, - label: t(labels.uniqueEvents), - change: uniqueEvents - comparison.uniqueEvents, - formatValue: formatLongNumber, - }, - ] - : null; - return ( - - - {metrics?.map(({ label, value, change, formatValue }) => { - return ( - - ); - })} - - + + + + {count} + + + {t(labels.online)} + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx new file mode 100644 index 000000000..c0f142804 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart.tsx @@ -0,0 +1,13 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useRealtimeQuery } from '@/components/hooks'; +import { RealtimeChart } from '@/components/metrics/RealtimeChart'; + +export function BoardRealtimeChart({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useRealtimeQuery(websiteId); + + return ( + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx new file mode 100644 index 000000000..15ffedf6d --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar.tsx @@ -0,0 +1,13 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useRealtimeQuery } from '@/components/hooks'; +import { RealtimeHeader } from './RealtimeHeader'; + +export function BoardRealtimeMetricsBar({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useRealtimeQuery(websiteId); + + return ( + + + + ); +} diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts index d3384f7d6..3de9a017f 100644 --- a/src/app/api/reports/[reportId]/route.ts +++ b/src/app/api/reports/[reportId]/route.ts @@ -15,6 +15,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo const report = await getReport(reportId); + if (!report) { + return notFound(); + } + if (!(await canViewReport(auth, report))) { return unauthorized(); } diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index 353f329e0..405be6623 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -4,33 +4,13 @@ import { parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema'; import { + getReportSection, canUpdateWebsite, canViewAuthenticatedWebsite, canViewWebsiteSection, } from '@/permissions'; -import type { ShareSection } from '@/permissions'; import { createReport, getReports } from '@/queries/prisma'; -function getReportSection(type?: z.infer): ShareSection | null { - switch (type) { - case 'attribution': - case 'breakdown': - case 'performance': - case 'retention': - case 'revenue': - case 'utm': - return type; - case 'funnel': - return 'funnels'; - case 'goal': - return 'goals'; - case 'journey': - return 'journeys'; - default: - return null; - } -} - export async function GET(request: Request) { const schema = z.object({ websiteId: z.uuid(), diff --git a/src/lib/boardComponentCompatibility.ts b/src/lib/boardComponentCompatibility.ts index 943701962..d04ced5e5 100644 --- a/src/lib/boardComponentCompatibility.ts +++ b/src/lib/boardComponentCompatibility.ts @@ -1,7 +1,18 @@ import type { BoardEntityType } from './boards'; export const BOARD_COMPONENT_COMPATIBILITY_MATRIX = { + EventsMetricsBar: ['website'], EventsChart: ['website'], + Funnel: ['website'], + Goal: ['website'], + RealtimeActiveUsers: ['website'], + RealtimeHeader: ['website'], + RealtimeChart: ['website'], + RevenueMetricsBar: ['website'], + RevenueChart: ['website'], + RevenueMetricsTable: ['website'], + UTM: ['website'], + WeeklyTraffic: ['website'], } as const satisfies Partial>; export function getSupportedBoardComponentEntityTypes(componentType: string) { diff --git a/src/lib/boards.test.ts b/src/lib/boards.test.ts index ce7d94fd1..f80e7c79e 100644 --- a/src/lib/boards.test.ts +++ b/src/lib/boards.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest'; +import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry'; import { BOARD_COMPONENT_COMPATIBILITY_MATRIX, getSupportedBoardComponentEntityTypes, @@ -14,14 +15,101 @@ test('isBoardComponentSupported rejects events chart on pixel and link boards', expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.link)).toBe(false); }); +test('isBoardComponentSupported rejects event metrics bar and weekly traffic on pixel and link boards', () => { + expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.pixel)).toBe(false); + expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.link)).toBe(false); + expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.pixel)).toBe(false); + expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.link)).toBe(false); +}); + test('board component compatibility matrix defines website-only components explicitly', () => { + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsMetricsBar).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsChart).toEqual([BOARD_ENTITY_TYPES.website]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Funnel).toEqual([BOARD_ENTITY_TYPES.website]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Goal).toEqual([BOARD_ENTITY_TYPES.website]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeActiveUsers).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeHeader).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeChart).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsBar).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueChart).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsTable).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.UTM).toEqual([BOARD_ENTITY_TYPES.website]); + expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.WeeklyTraffic).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('EventsMetricsBar')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); expect(getSupportedBoardComponentEntityTypes('EventsChart')).toEqual([ BOARD_ENTITY_TYPES.website, ]); + expect(getSupportedBoardComponentEntityTypes('Funnel')).toEqual([BOARD_ENTITY_TYPES.website]); + expect(getSupportedBoardComponentEntityTypes('Goal')).toEqual([BOARD_ENTITY_TYPES.website]); + expect(getSupportedBoardComponentEntityTypes('RealtimeActiveUsers')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('RealtimeHeader')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('RealtimeChart')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('RevenueMetricsBar')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('RevenueChart')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('RevenueMetricsTable')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); + expect(getSupportedBoardComponentEntityTypes('UTM')).toEqual([BOARD_ENTITY_TYPES.website]); + expect(getSupportedBoardComponentEntityTypes('WeeklyTraffic')).toEqual([ + BOARD_ENTITY_TYPES.website, + ]); }); test('isBoardComponentSupported leaves other components available for all board entities', () => { expect(isBoardComponentSupported('WebsiteChart', BOARD_ENTITY_TYPES.pixel)).toBe(true); expect(isBoardComponentSupported('TextBlock', BOARD_ENTITY_TYPES.link)).toBe(true); }); + +test('metrics table limits pixel and link boards to overview-page metric types', () => { + const definition = getComponentDefinition('MetricsTable'); + const options = definition?.configFields?.find(field => field.name === 'type')?.optionsByEntityType; + + expect(options?.pixel?.map(option => option.value)).toEqual([ + 'referrer', + 'channel', + 'country', + 'region', + 'city', + 'browser', + 'os', + 'device', + ]); + expect(options?.link?.map(option => option.value)).toEqual([ + 'referrer', + 'channel', + 'country', + 'region', + 'city', + 'browser', + 'os', + 'device', + ]); +}); diff --git a/src/permissions/report.test.ts b/src/permissions/report.test.ts new file mode 100644 index 000000000..46be11227 --- /dev/null +++ b/src/permissions/report.test.ts @@ -0,0 +1,125 @@ +import { expect, test, vi } from 'vitest'; +import { ENTITY_TYPE } from '@/lib/constants'; +import { canViewReport, getReportSection } from './report'; +import { canViewWebsite } from './website'; + +vi.mock('./website', () => ({ + canViewWebsite: vi.fn(), +})); + +test('getReportSection maps saved report types to share sections', () => { + expect(getReportSection('goal')).toBe('goals'); + expect(getReportSection('funnel')).toBe('funnels'); + expect(getReportSection('journey')).toBe('journeys'); + expect(getReportSection('revenue')).toBe('revenue'); + expect(getReportSection('heatmap')).toBe(null); +}); + +test('canViewReport allows board shares to fetch included saved goal reports', async () => { + await expect( + canViewReport( + { + shareToken: { + shareType: ENTITY_TYPE.board, + websiteIds: ['website-1'], + parameters: {}, + }, + }, + { + id: 'report-1', + userId: 'owner-1', + websiteId: 'website-1', + type: 'goal', + } as any, + ), + ).resolves.toBe(true); +}); + +test('canViewReport respects share section flags for saved funnel and goal reports', async () => { + await expect( + canViewReport( + { + shareToken: { + shareType: ENTITY_TYPE.website, + websiteId: 'website-1', + parameters: { + overview: true, + funnels: false, + goals: false, + }, + }, + }, + { + id: 'report-1', + userId: 'owner-1', + websiteId: 'website-1', + type: 'funnel', + } as any, + ), + ).resolves.toBe(false); + + await expect( + canViewReport( + { + shareToken: { + shareType: ENTITY_TYPE.website, + websiteId: 'website-1', + parameters: { + overview: true, + funnels: false, + goals: true, + }, + }, + }, + { + id: 'report-2', + userId: 'owner-1', + websiteId: 'website-1', + type: 'goal', + } as any, + ), + ).resolves.toBe(true); +}); + +test('canViewReport denies share-token access to report types without a share section', async () => { + await expect( + canViewReport( + { + shareToken: { + shareType: ENTITY_TYPE.website, + websiteId: 'website-1', + parameters: {}, + }, + }, + { + id: 'report-1', + userId: 'owner-1', + websiteId: 'website-1', + type: 'heatmap', + } as any, + ), + ).resolves.toBe(false); +}); + +test('canViewReport falls back to website access for authenticated users', async () => { + vi.mocked(canViewWebsite).mockResolvedValue(true); + + await expect( + canViewReport( + { + user: { + id: 'user-1', + username: 'user', + role: 'user', + isAdmin: false, + }, + }, + { + id: 'report-1', + userId: 'owner-1', + websiteId: 'website-1', + type: 'heatmap', + } as any, + ), + ).resolves.toBe(true); +}); diff --git a/src/permissions/report.ts b/src/permissions/report.ts index fec37d8ca..cdec31ddc 100644 --- a/src/permissions/report.ts +++ b/src/permissions/report.ts @@ -1,8 +1,34 @@ import type { Report } from '@/generated/prisma/client'; import type { Auth } from '@/lib/types'; +import type { ShareSection } from './share'; +import { canViewWebsiteSection } from './share'; import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from './website'; -export async function canViewReport(auth: Auth, report: Report) { +export function getReportSection(type?: string): ShareSection | null { + switch (type) { + case 'attribution': + case 'breakdown': + case 'performance': + case 'retention': + case 'revenue': + case 'utm': + return type; + case 'funnel': + return 'funnels'; + case 'goal': + return 'goals'; + case 'journey': + return 'journeys'; + default: + return null; + } +} + +export async function canViewReport(auth: Auth, report: Report | null) { + if (!report) { + return false; + } + if (auth.user?.isAdmin) { return true; } @@ -11,7 +37,13 @@ export async function canViewReport(auth: Auth, report: Report) { return true; } - return !!(await canViewWebsite(auth, report.websiteId)); + const section = getReportSection(report.type); + + if (section) { + return !!(await canViewWebsiteSection(auth, report.websiteId, section)); + } + + return !!auth.user && !!(await canViewWebsite(auth, report.websiteId)); } export async function canUpdateReport(auth: Auth, report: Report) {