From 7a88b262c03eb1aa87e7d2ca0ed85ac4d2cda121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 5 Feb 2026 21:24:37 +0000 Subject: [PATCH] fix(dashboard): share overview (all widgets didnt work) --- .../src/components/overview/live-counter.tsx | 4 +- .../overview/overview-live-histogram.tsx | 4 +- .../src/components/overview/overview-map.tsx | 90 +++++ .../components/overview/overview-metrics.tsx | 49 +-- .../overview/overview-top-devices.tsx | 4 + .../overview/overview-top-events.tsx | 126 ++++--- .../components/overview/overview-top-geo.tsx | 38 +- .../overview/overview-top-pages.tsx | 7 +- .../overview/overview-top-sources.tsx | 4 + .../overview/overview-user-journey.tsx | 3 + apps/start/src/modals/overview-filters.tsx | 2 +- .../src/routes/share.overview.$shareId.tsx | 19 +- packages/db/src/services/overview.service.ts | 190 +++++++++- packages/db/src/services/share.service.ts | 64 ++++ packages/trpc/src/routers/overview.ts | 352 +++++++++++++++++- 15 files changed, 820 insertions(+), 136 deletions(-) create mode 100644 apps/start/src/components/overview/overview-map.tsx diff --git a/apps/start/src/components/overview/live-counter.tsx b/apps/start/src/components/overview/live-counter.tsx index 5f312715..2333f5c0 100644 --- a/apps/start/src/components/overview/live-counter.tsx +++ b/apps/start/src/components/overview/live-counter.tsx @@ -10,11 +10,12 @@ import { AnimatedNumber } from '../animated-number'; export interface LiveCounterProps { projectId: string; + shareId?: string; } const FIFTEEN_SECONDS = 1000 * 30; -export function LiveCounter({ projectId }: LiveCounterProps) { +export function LiveCounter({ projectId, shareId }: LiveCounterProps) { const trpc = useTRPC(); const client = useQueryClient(); const counter = useDebounceState(0, 1000); @@ -22,6 +23,7 @@ export function LiveCounter({ projectId }: LiveCounterProps) { const query = useQuery( trpc.overview.liveVisitors.queryOptions({ projectId, + shareId, }), ); diff --git a/apps/start/src/components/overview/overview-live-histogram.tsx b/apps/start/src/components/overview/overview-live-histogram.tsx index 61dbf081..6de8030c 100644 --- a/apps/start/src/components/overview/overview-live-histogram.tsx +++ b/apps/start/src/components/overview/overview-live-histogram.tsx @@ -18,16 +18,18 @@ import { import { SerieIcon } from '../report-chart/common/serie-icon'; interface OverviewLiveHistogramProps { projectId: string; + shareId?: string; } export function OverviewLiveHistogram({ projectId, + shareId, }: OverviewLiveHistogramProps) { const trpc = useTRPC(); // Use the new liveData endpoint instead of chart props const { data: liveData, isLoading } = useQuery( - trpc.overview.liveData.queryOptions({ projectId }), + trpc.overview.liveData.queryOptions({ projectId, shareId }), ); const totalSessions = liveData?.totalSessions ?? 0; diff --git a/apps/start/src/components/overview/overview-map.tsx b/apps/start/src/components/overview/overview-map.tsx new file mode 100644 index 00000000..7a0b4b0d --- /dev/null +++ b/apps/start/src/components/overview/overview-map.tsx @@ -0,0 +1,90 @@ +import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import WorldMap from 'react-svg-worldmap'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { useOverviewOptions } from './useOverviewOptions'; + +interface OverviewMapProps { + projectId: string; + shareId?: string; +} + +export function OverviewMap({ projectId, shareId }: OverviewMapProps) { + const { range, startDate, endDate } = useOverviewOptions(); + const [filters, setFilter] = useEventQueryFilters(); + const trpc = useTRPC(); + + const query = useQuery( + trpc.overview.map.queryOptions({ + projectId, + shareId, + range, + filters, + startDate, + endDate, + }), + ); + + const mapData = useMemo(() => { + if (!query.data) return []; + + // Aggregate by country (sum counts for same country) + const countryMap = new Map(); + query.data.forEach((item) => { + const country = item.country.toLowerCase(); + const current = countryMap.get(country) ?? 0; + countryMap.set(country, current + item.count); + }); + + return Array.from(countryMap.entries()).map(([country, value]) => ({ + country, + value, + })); + }, [query.data]); + + if (query.isLoading) { + return ( +
+
Loading map...
+
+ ); + } + + if (query.isError) { + return ( +
+
Error loading map
+
+ ); + } + + if (!query.data || mapData.length === 0) { + return ( +
+
No data available
+
+ ); + } + + return ( +
+ + {({ width }) => ( + { + if (event.countryCode) { + setFilter('country', event.countryCode); + } + }} + size={width} + data={mapData} + color={'var(--chart-0)'} + borderColor={'var(--foreground)'} + /> + )} + +
+ ); +} diff --git a/apps/start/src/components/overview/overview-metrics.tsx b/apps/start/src/components/overview/overview-metrics.tsx index b9be5739..7ffed140 100644 --- a/apps/start/src/components/overview/overview-metrics.tsx +++ b/apps/start/src/components/overview/overview-metrics.tsx @@ -36,6 +36,7 @@ import { OverviewMetricCard } from './overview-metric-card'; interface OverviewMetricsProps { projectId: string; + shareId?: string; } const TITLES = [ @@ -83,7 +84,10 @@ const TITLES = [ }, ] as const; -export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { +export default function OverviewMetrics({ + projectId, + shareId, +}: OverviewMetricsProps) { const { range, interval, metric, setMetric, startDate, endDate } = useOverviewOptions(); const [filters] = useEventQueryFilters(); @@ -93,6 +97,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { const overviewQuery = useQuery( trpc.overview.stats.queryOptions({ projectId, + shareId, range, interval, filters, @@ -138,7 +143,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { 'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1', )} > - + @@ -344,7 +349,7 @@ function Chart({ onAnimationEnd={handleAnimationEnd} /> - + Math.max(dataMax, 1), + ]} width={25} /> Math.max(max, item.total_revenue ?? 0), - 0, - ) * 2, + Math.max( + data.reduce( + (max, item) => Math.max(max, item.total_revenue ?? 0), + 0, + ) * 1.2, + 1, + ), ]} width={30} + allowDataOverflow={false} /> - + 90 - ? false - : { - stroke: 'oklch(from var(--foreground) l c h / 0.1)', - fill: 'transparent', - strokeWidth: 1.5, - r: 2, - } - } + dot={false} activeDot={{ stroke: 'oklch(from var(--foreground) l c h / 0.2)', - fill: 'transparent', + fill: 'var(--def-100)', + fillOpacity: 1, strokeWidth: 1.5, r: 3, }} @@ -581,7 +587,8 @@ function Chart({ ? false : { stroke: getChartColor(0), - fill: 'transparent', + fill: 'var(--def-100)', + fillOpacity: 1, strokeWidth: 1.5, r: 3, } diff --git a/apps/start/src/components/overview/overview-top-devices.tsx b/apps/start/src/components/overview/overview-top-devices.tsx index 31383643..a9d3e7f7 100644 --- a/apps/start/src/components/overview/overview-top-devices.tsx +++ b/apps/start/src/components/overview/overview-top-devices.tsx @@ -26,9 +26,11 @@ import { useOverviewWidget } from './useOverviewWidget'; interface OverviewTopDevicesProps { projectId: string; + shareId?: string; } export default function OverviewTopDevices({ projectId, + shareId, }: OverviewTopDevicesProps) { const { interval, range, previous, startDate, endDate } = useOverviewOptions(); @@ -325,6 +327,7 @@ export default function OverviewTopDevices({ const query = useQuery( trpc.overview.topGeneric.queryOptions({ projectId, + shareId, range, filters, column: widget.key, @@ -337,6 +340,7 @@ export default function OverviewTopDevices({ trpc.overview.topGenericSeries.queryOptions( { projectId, + shareId, range, filters, column: widget.key, diff --git a/apps/start/src/components/overview/overview-top-events.tsx b/apps/start/src/components/overview/overview-top-events.tsx index a4d4dc7f..b69b8f60 100644 --- a/apps/start/src/components/overview/overview-top-events.tsx +++ b/apps/start/src/components/overview/overview-top-events.tsx @@ -1,8 +1,6 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useMemo, useState } from 'react'; -import type { IReportInput } from '@openpanel/validation'; - import { useTRPC } from '@/integrations/trpc/react'; import { useQuery } from '@tanstack/react-query'; import { Widget, WidgetBody } from '../widget'; @@ -17,17 +15,18 @@ import { useOverviewWidgetV2 } from './useOverviewWidget'; export interface OverviewTopEventsProps { projectId: string; + shareId?: string; } export default function OverviewTopEvents({ projectId, + shareId, }: OverviewTopEventsProps) { - const { interval, range, previous, startDate, endDate } = - useOverviewOptions(); + const { range, startDate, endDate } = useOverviewOptions(); const [filters, setFilter] = useEventQueryFilters(); const trpc = useTRPC(); const { data: conversions } = useQuery( - trpc.event.conversionNames.queryOptions({ projectId }), + trpc.overview.topConversions.queryOptions({ projectId, shareId }), ); const [searchQuery, setSearchQuery] = useState(''); @@ -36,15 +35,7 @@ export default function OverviewTopEvents({ title: 'Events', btn: 'Events', meta: { - filters: [ - { - id: 'ex_session', - name: 'name', - operator: 'isNot', - value: ['session_start', 'session_end', 'screen_view'], - }, - ], - eventName: '*', + type: 'events' as const, }, }, conversions: { @@ -52,69 +43,84 @@ export default function OverviewTopEvents({ btn: 'Conversions', hide: !conversions || conversions.length === 0, meta: { - filters: [ - { - id: 'conversion', - name: 'name', - operator: 'is', - value: conversions?.map((c) => c.name) ?? [], - }, - ], - eventName: '*', + type: 'conversions' as const, }, }, link_out: { title: 'Link out', btn: 'Link out', meta: { - filters: [], - eventName: 'link_out', - breakdownProperty: 'properties.href', + type: 'linkOut' as const, }, }, }); - const report: IReportInput = useMemo( - () => ({ - limit: 1000, + // Use different endpoints based on widget type + const eventsQuery = useQuery( + trpc.overview.topEvents.queryOptions({ projectId, + shareId, + range, startDate, endDate, - series: [ - { - type: 'event' as const, - segment: 'event' as const, - filters: [...filters, ...(widget.meta?.filters ?? [])], - id: 'A', - name: widget.meta?.eventName ?? '*', - }, - ], - breakdowns: [ - { - id: 'A', - name: widget.meta?.breakdownProperty ?? 'name', - }, - ], - chartType: 'bar' as const, - interval, - range, - previous, - metric: 'sum' as const, + filters, + excludeEvents: + widget.meta?.type === 'events' + ? ['session_start', 'session_end', 'screen_view'] + : undefined, }), - [projectId, startDate, endDate, filters, widget, interval, range, previous], ); - const query = useQuery(trpc.chart.aggregate.queryOptions(report)); + const linkOutQuery = useQuery( + trpc.overview.topLinkOut.queryOptions({ + projectId, + shareId, + range, + startDate, + endDate, + filters, + }), + ); const tableData: EventTableItem[] = useMemo(() => { - if (!query.data?.series) return []; + // For link out, use href as name + if (widget.meta?.type === 'linkOut') { + if (!linkOutQuery.data) return []; + return linkOutQuery.data.map((item) => ({ + id: item.href, + name: item.href, + count: item.count, + })); + } - return query.data.series.map((serie) => ({ - id: serie.id, - name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '', - count: serie.metrics.sum, + // For events and conversions + if (!eventsQuery.data) return []; + + // For conversions, filter events by conversion names (client-side filtering) + if (widget.meta?.type === 'conversions' && conversions) { + const conversionNames = new Set(conversions.map((c) => c.name)); + return eventsQuery.data + .filter((item) => conversionNames.has(item.name)) + .map((item) => ({ + id: item.name, + name: item.name, + count: item.count, + })); + } + + // For regular events + return eventsQuery.data.map((item) => ({ + id: item.name, + name: item.name, + count: item.count, })); - }, [query.data]); + }, [eventsQuery.data, linkOutQuery.data, widget.meta?.type, conversions]); + + // Determine which query's loading state to use + const isLoading = + widget.meta?.type === 'linkOut' + ? linkOutQuery.isLoading + : eventsQuery.isLoading; const filteredData = useMemo(() => { if (!searchQuery.trim()) { @@ -150,14 +156,14 @@ export default function OverviewTopEvents({ className="border-b-0 pb-2" /> - {query.isLoading ? ( + {isLoading ? ( ) : ( { - if (widget.meta?.breakdownProperty) { - setFilter(widget.meta.breakdownProperty, name); + if (widget.meta?.type === 'linkOut') { + setFilter('properties.href', name); } else { setFilter('name', name); } diff --git a/apps/start/src/components/overview/overview-top-geo.tsx b/apps/start/src/components/overview/overview-top-geo.tsx index ac03ec0d..a08d8556 100644 --- a/apps/start/src/components/overview/overview-top-geo.tsx +++ b/apps/start/src/components/overview/overview-top-geo.tsx @@ -9,9 +9,7 @@ import { countries } from '@/translations/countries'; import { NOT_SET_VALUE } from '@openpanel/constants'; import { useQuery } from '@tanstack/react-query'; import { ChevronRightIcon } from 'lucide-react'; -import { ReportChart } from '../report-chart'; import { SerieIcon } from '../report-chart/common/serie-icon'; -import { ReportChartShortcut } from '../report-chart/shortcut'; import { Widget, WidgetBody } from '../widget'; import { OVERVIEW_COLUMNS_NAME } from './overview-constants'; import OverviewDetailsButton from './overview-details-button'; @@ -19,6 +17,7 @@ import { OverviewLineChart, OverviewLineChartLoading, } from './overview-line-chart'; +import { OverviewMap } from './overview-map'; import { OverviewViewToggle, useOverviewView } from './overview-view-toggle'; import { WidgetFooter, @@ -34,8 +33,12 @@ import { useOverviewWidgetV2 } from './useOverviewWidget'; interface OverviewTopGeoProps { projectId: string; + shareId?: string; } -export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { +export default function OverviewTopGeo({ + projectId, + shareId, +}: OverviewTopGeoProps) { const { interval, range, previous, startDate, endDate } = useOverviewOptions(); const [chartType, setChartType] = useState('bar'); @@ -63,6 +66,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { const query = useQuery( trpc.overview.topGeneric.queryOptions({ projectId, + shareId, range, filters, column: widget.key, @@ -75,6 +79,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { trpc.overview.topGenericSeries.queryOptions( { projectId, + shareId, range, filters, column: widget.key, @@ -211,32 +216,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
Map
- + diff --git a/apps/start/src/components/overview/overview-top-pages.tsx b/apps/start/src/components/overview/overview-top-pages.tsx index 5fe69967..2e801f45 100644 --- a/apps/start/src/components/overview/overview-top-pages.tsx +++ b/apps/start/src/components/overview/overview-top-pages.tsx @@ -20,9 +20,13 @@ import { useOverviewWidgetV2 } from './useOverviewWidget'; interface OverviewTopPagesProps { projectId: string; + shareId?: string; } -export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { +export default function OverviewTopPages({ + projectId, + shareId, +}: OverviewTopPagesProps) { const { interval, range, startDate, endDate } = useOverviewOptions(); const [filters] = useEventQueryFilters(); const [domain, setDomain] = useQueryState('d', parseAsBoolean); @@ -56,6 +60,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { const query = useQuery( trpc.overview.topPages.queryOptions({ projectId, + shareId, filters, startDate, endDate, diff --git a/apps/start/src/components/overview/overview-top-sources.tsx b/apps/start/src/components/overview/overview-top-sources.tsx index 05e7772d..41a498b9 100644 --- a/apps/start/src/components/overview/overview-top-sources.tsx +++ b/apps/start/src/components/overview/overview-top-sources.tsx @@ -24,9 +24,11 @@ import { useOverviewWidgetV2 } from './useOverviewWidget'; interface OverviewTopSourcesProps { projectId: string; + shareId?: string; } export default function OverviewTopSources({ projectId, + shareId, }: OverviewTopSourcesProps) { const { interval, range, startDate, endDate } = useOverviewOptions(); const [filters, setFilter] = useEventQueryFilters(); @@ -71,6 +73,7 @@ export default function OverviewTopSources({ const query = useQuery( trpc.overview.topGeneric.queryOptions({ projectId, + shareId, range, filters, column: widget.key, @@ -83,6 +86,7 @@ export default function OverviewTopSources({ trpc.overview.topGenericSeries.queryOptions( { projectId, + shareId, range, filters, column: widget.key, diff --git a/apps/start/src/components/overview/overview-user-journey.tsx b/apps/start/src/components/overview/overview-user-journey.tsx index d3894ae6..9f95f6d4 100644 --- a/apps/start/src/components/overview/overview-user-journey.tsx +++ b/apps/start/src/components/overview/overview-user-journey.tsx @@ -29,6 +29,7 @@ import { useOverviewOptions } from './useOverviewOptions'; interface OverviewUserJourneyProps { projectId: string; + shareId?: string; } type PortalTooltipPosition = { left: number; top: number; ready: boolean }; @@ -159,6 +160,7 @@ function SankeyPortalTooltip({ export default function OverviewUserJourney({ projectId, + shareId, }: OverviewUserJourneyProps) { const { range, startDate, endDate } = useOverviewOptions(); const [filters] = useEventQueryFilters(); @@ -177,6 +179,7 @@ export default function OverviewUserJourney({ endDate, range, steps: steps ?? 5, + shareId, }), ); diff --git a/apps/start/src/modals/overview-filters.tsx b/apps/start/src/modals/overview-filters.tsx index 1c516d67..574b1d17 100644 --- a/apps/start/src/modals/overview-filters.tsx +++ b/apps/start/src/modals/overview-filters.tsx @@ -38,7 +38,7 @@ export default function OverviewFilters({ const { projectId } = useAppParams(); const [filters, setFilter] = useEventQueryFilters(nuqsOptions); const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions); - const eventNames = useEventNames({ projectId }); + const eventNames = useEventNames({ projectId, anyEvents: false }); const selectedFilters = filters.filter((filter) => filter.value[0] !== null); return ( diff --git a/apps/start/src/routes/share.overview.$shareId.tsx b/apps/start/src/routes/share.overview.$shareId.tsx index 43734311..e20fa3e6 100644 --- a/apps/start/src/routes/share.overview.$shareId.tsx +++ b/apps/start/src/routes/share.overview.$shareId.tsx @@ -1,6 +1,7 @@ import { ShareEnterPassword } from '@/components/auth/share-enter-password'; import { FullPageEmptyState } from '@/components/full-page-empty-state'; import FullPageLoadingState from '@/components/full-page-loading-state'; +import { LazyComponent } from '@/components/lazy-component'; import { LoginNavbar } from '@/components/login-navbar'; import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { LiveCounter } from '@/components/overview/live-counter'; @@ -11,6 +12,7 @@ import OverviewTopEvents from '@/components/overview/overview-top-events'; import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopSources from '@/components/overview/overview-top-sources'; +import OverviewUserJourney from '@/components/overview/overview-user-journey'; import { useTRPC } from '@/integrations/trpc/react'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; @@ -110,19 +112,22 @@ function RouteComponent() {
- +
- - - - - - + + + + + + + + +
); diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index 71282e24..cc65ebcf 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -3,10 +3,11 @@ import { chartColors } from '@openpanel/constants'; import { getCache } from '@openpanel/redis'; import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation'; import { omit } from 'ramda'; +import sqlstring from 'sqlstring'; import { z } from 'zod'; import { TABLE_NAMES, ch } from '../clickhouse/client'; import { clix } from '../clickhouse/query-builder'; -import { getEventFiltersWhereClause } from './chart.service'; +import { getEventFiltersWhereClause, getSelectPropertyKey } from './chart.service'; // Constants const ROLLUP_DATE_PREFIX = '1970-01-01'; @@ -127,12 +128,53 @@ export type IGetUserJourneyInput = z.infer & { timezone: string; }; +export const zGetTopEventsInput = z.object({ + projectId: z.string(), + filters: z.array(z.any()), + startDate: z.string(), + endDate: z.string(), + excludeEvents: z.array(z.string()).optional(), +}); + +export type IGetTopEventsInput = z.infer & { + timezone: string; +}; + +export const zGetTopLinkOutInput = z.object({ + projectId: z.string(), + filters: z.array(z.any()), + startDate: z.string(), + endDate: z.string(), +}); + +export type IGetTopLinkOutInput = z.infer & { + timezone: string; +}; + +export const zGetMapDataInput = z.object({ + projectId: z.string(), + filters: z.array(z.any()), + startDate: z.string(), + endDate: z.string(), +}); + +export type IGetMapDataInput = z.infer & { + timezone: string; +}; + export class OverviewService { constructor(private client: typeof ch) {} // Helper methods private isRollupRow(date: string): boolean { - return date.startsWith(ROLLUP_DATE_PREFIX); + // The rollup row has date 1970-01-01 00:00:00 (epoch) from ClickHouse. + // After transform with `new Date().toISOString()`, this becomes an ISO string. + // Due to timezone handling in JavaScript's Date constructor (which interprets + // the input as local time), the UTC date might become: + // - 1969-12-31T... for positive UTC offsets (e.g., UTC+8) + // - 1970-01-01T... for UTC or negative offsets + // We check for both year prefixes to handle all server timezones. + return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31'); } private getFillConfig(interval: string, startDate: string, endDate: string) { @@ -1223,6 +1265,150 @@ export class OverviewService { links: filteredLinks, }; } + + async getTopEvents({ + projectId, + filters, + startDate, + endDate, + timezone, + excludeEvents = ['session_start', 'session_end', 'screen_view'], + }: { + projectId: string; + filters: IChartEventFilter[]; + startDate: string; + endDate: string; + timezone: string; + excludeEvents?: string[]; + }): Promise> { + const where = this.getRawWhereClause('events', filters); + const excludeWhere = + excludeEvents.length > 0 + ? `name NOT IN (${excludeEvents.map((e) => sqlstring.escape(e)).join(',')})` + : ''; + + const query = clix(this.client, timezone) + .select<{ name: string; count: number }>([ + 'name', + 'count() as count', + ]) + .from(TABLE_NAMES.events, false) + .where('project_id', '=', projectId) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate, 'toDateTime'), + clix.datetime(endDate, 'toDateTime'), + ]) + .rawWhere(where) + .rawWhere(excludeWhere) + .groupBy(['name']) + .orderBy('count', 'DESC') + .limit(MAX_RECORDS_LIMIT); + + return query.execute(); + } + + async getTopLinkOut({ + projectId, + filters, + startDate, + endDate, + timezone, + }: { + projectId: string; + filters: IChartEventFilter[]; + startDate: string; + endDate: string; + timezone: string; + }): Promise> { + const where = this.getRawWhereClause('events', filters); + const hrefKey = getSelectPropertyKey('properties.href'); + + const query = clix(this.client, timezone) + .select<{ href: string; count: number }>([ + `${hrefKey} as href`, + 'count() as count', + ]) + .from(TABLE_NAMES.events, false) + .where('project_id', '=', projectId) + .where('name', '=', 'link_out') + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate, 'toDateTime'), + clix.datetime(endDate, 'toDateTime'), + ]) + .rawWhere(where) + .rawWhere(`${hrefKey} IS NOT NULL AND ${hrefKey} != ''`) + .groupBy(['href']) + .orderBy('count', 'DESC') + .limit(MAX_RECORDS_LIMIT); + + return query.execute(); + } + + async getMapData({ + projectId, + filters, + startDate, + endDate, + timezone, + }: { + projectId: string; + filters: IChartEventFilter[]; + startDate: string; + endDate: string; + timezone: string; + }): Promise< + Array<{ + country: string; + region?: string; + city?: string; + lat: number; + lng: number; + count: number; + }> + > { + const where = this.getRawWhereClause('events', filters); + + // Note: ClickHouse doesn't have built-in lat/lng for countries/regions + // This would typically require a lookup table or external service + // For now, we'll return the data structure but lat/lng would need to be + // resolved on the frontend or via a separate lookup + const query = clix(this.client, timezone) + .select<{ + country: string; + region: string | null; + city: string | null; + count: number; + }>([ + 'nullIf(country, \'\') as country', + 'nullIf(region, \'\') as region', + 'nullIf(city, \'\') as city', + 'uniq(session_id) as count', + ]) + .from(TABLE_NAMES.events, false) + .where('project_id', '=', projectId) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate, 'toDateTime'), + clix.datetime(endDate, 'toDateTime'), + ]) + .rawWhere(where) + .rawWhere('country IS NOT NULL AND country != \'\'') + .groupBy(['country', 'region', 'city']) + .orderBy('count', 'DESC') + .limit(MAX_RECORDS_LIMIT); + + const results = await query.execute(); + + // Return with placeholder lat/lng - these should be resolved via geocoding + // or a lookup table on the frontend/backend + return results.map((row) => ({ + country: row.country, + region: row.region ?? undefined, + city: row.city ?? undefined, + lat: 0, // Placeholder - needs geocoding + lng: 0, // Placeholder - needs geocoding + count: row.count, + })); + } } export const overviewService = new OverviewService(ch); diff --git a/packages/db/src/services/share.service.ts b/packages/db/src/services/share.service.ts index eac8f06f..2e936991 100644 --- a/packages/db/src/services/share.service.ts +++ b/packages/db/src/services/share.service.ts @@ -213,3 +213,67 @@ export async function validateShareAccess( throw new Error('Share not found'); } + +// Validation for overview share access +export async function validateOverviewShareAccess( + shareId: string | undefined, + projectId: string, + ctx: { + cookies: Record; + session?: { userId?: string | null }; + }, +): Promise<{ isValid: boolean }> { + // If shareId is provided, validate share access + if (shareId) { + const share = await db.shareOverview.findUnique({ + where: { id: shareId }, + }); + + if (!share || !share.public) { + throw new Error('Share not found or not public'); + } + + // Verify the share is for the correct project + if (share.projectId !== projectId) { + throw new Error('Project ID mismatch'); + } + + // If no password is set, share is public and accessible + if (!share.password) { + return { + isValid: true, + }; + } + + // If password is set, require cookie OR member access + const hasCookie = !!ctx.cookies[`shared-overview-${shareId}`]; + const hasMemberAccess = + ctx.session?.userId && + (await getProjectAccess({ + userId: ctx.session.userId, + projectId, + })); + + return { + isValid: hasCookie || !!hasMemberAccess, + }; + } + + // If no shareId, require authenticated user with project access + if (!ctx.session?.userId) { + throw new Error('Authentication required'); + } + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId, + }); + + if (!access) { + throw new Error('You do not have access to this project'); + } + + return { + isValid: true, + }; +} diff --git a/packages/trpc/src/routers/overview.ts b/packages/trpc/src/routers/overview.ts index a28825bd..6a9b783f 100644 --- a/packages/trpc/src/routers/overview.ts +++ b/packages/trpc/src/routers/overview.ts @@ -5,18 +5,25 @@ import { eventBuffer, getChartPrevStartEndDate, getChartStartEndDate, + getConversionEventNames, getOrganizationSubscriptionChartEndDate, getSettingsForProject, overviewService, + validateOverviewShareAccess, + zGetMapDataInput, zGetMetricsInput, + zGetTopEventsInput, zGetTopGenericInput, zGetTopGenericSeriesInput, + zGetTopLinkOutInput, zGetTopPagesInput, zGetUserJourneyInput, } from '@openpanel/db'; import { type IChartRange, zRange } from '@openpanel/validation'; import { format } from 'date-fns'; import { z } from 'zod'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError } from '../errors'; import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc'; const cacher = cacheMiddleware((input, opts) => { @@ -87,15 +94,58 @@ function getCurrentAndPrevious< export const overviewRouter = createTRPCRouter({ liveVisitors: publicProcedure - .input(z.object({ projectId: z.string() })) - .query(async ({ input }) => { + .input(z.object({ projectId: z.string(), shareId: z.string().optional() })) + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } + return eventBuffer.getActiveVisitorCount(input.projectId); }), liveData: publicProcedure - .input(z.object({ projectId: z.string() })) + .input(z.object({ projectId: z.string(), shareId: z.string().optional() })) .use(cacher) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } const { timezone } = await getSettingsForProject(input.projectId); // Get total unique sessions in the last 30 minutes @@ -212,10 +262,32 @@ export const overviewRouter = createTRPCRouter({ startDate: z.string().nullish(), endDate: z.string().nullish(), range: zRange, + shareId: z.string().optional(), }), ) .use(cacher) .query(async ({ ctx, input }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } const { timezone } = await getSettingsForProject(input.projectId); const { current, previous } = await getCurrentAndPrevious( { ...input, timezone }, @@ -258,10 +330,32 @@ export const overviewRouter = createTRPCRouter({ endDate: z.string().nullish(), range: zRange, mode: z.enum(['page', 'entry', 'exit', 'bot']), + shareId: z.string().optional(), }), ) .use(cacher) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } const { timezone } = await getSettingsForProject(input.projectId); const { current } = await getCurrentAndPrevious( { ...input }, @@ -292,10 +386,34 @@ export const overviewRouter = createTRPCRouter({ startDate: z.string().nullish(), endDate: z.string().nullish(), range: zRange, + shareId: z.string().optional(), }), ) .use(cacher) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + console.log('input', input); + + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } const { timezone } = await getSettingsForProject(input.projectId); const { current } = await getCurrentAndPrevious( { ...input, timezone }, @@ -308,14 +426,38 @@ export const overviewRouter = createTRPCRouter({ topGenericSeries: publicProcedure .input( - zGetTopGenericSeriesInput.omit({ startDate: true, endDate: true }).extend({ - startDate: z.string().nullish(), - endDate: z.string().nullish(), - range: zRange, - }), + zGetTopGenericSeriesInput + .omit({ startDate: true, endDate: true }) + .extend({ + startDate: z.string().nullish(), + endDate: z.string().nullish(), + range: zRange, + shareId: z.string().optional(), + }), ) .use(cacher) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } const { timezone } = await getSettingsForProject(input.projectId); const { current } = await getCurrentAndPrevious( { ...input, timezone }, @@ -333,10 +475,32 @@ export const overviewRouter = createTRPCRouter({ endDate: z.string().nullish(), range: zRange, steps: z.number().min(2).max(10).default(5).optional(), + shareId: z.string().optional(), }), ) .use(cacher) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } const { timezone } = await getSettingsForProject(input.projectId); const { current } = await getCurrentAndPrevious( { ...input, timezone }, @@ -350,6 +514,168 @@ export const overviewRouter = createTRPCRouter({ }); }); + return current; + }), + + topEvents: publicProcedure + .input( + zGetTopEventsInput.omit({ startDate: true, endDate: true }).extend({ + startDate: z.string().nullish(), + endDate: z.string().nullish(), + range: zRange, + shareId: z.string().optional(), + }), + ) + .use(cacher) + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } + + const { timezone } = await getSettingsForProject(input.projectId); + const { current } = await getCurrentAndPrevious( + { ...input, timezone }, + false, + timezone, + )(overviewService.getTopEvents.bind(overviewService)); + + return current; + }), + + topConversions: publicProcedure + .input( + z.object({ + projectId: z.string(), + shareId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } + + return getConversionEventNames(input.projectId); + }), + + topLinkOut: publicProcedure + .input( + zGetTopLinkOutInput.omit({ startDate: true, endDate: true }).extend({ + startDate: z.string().nullish(), + endDate: z.string().nullish(), + range: zRange, + shareId: z.string().optional(), + }), + ) + .use(cacher) + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } + + const { timezone } = await getSettingsForProject(input.projectId); + const { current } = await getCurrentAndPrevious( + { ...input, timezone }, + false, + timezone, + )(overviewService.getTopLinkOut.bind(overviewService)); + + return current; + }), + + map: publicProcedure + .input( + zGetMapDataInput.omit({ startDate: true, endDate: true }).extend({ + startDate: z.string().nullish(), + endDate: z.string().nullish(), + range: zRange, + shareId: z.string().optional(), + }), + ) + .use(cacher) + .query(async ({ input, ctx }) => { + // Validate share access if shareId provided + if (input.shareId) { + await validateOverviewShareAccess(input.shareId, input.projectId, { + cookies: ctx.cookies, + session: ctx.session?.userId + ? { userId: ctx.session.userId } + : undefined, + }); + } else { + // Regular member access check + if (!ctx.session?.userId) { + throw TRPCAccessError('Authentication required'); + } + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } + + const { timezone } = await getSettingsForProject(input.projectId); + const { current } = await getCurrentAndPrevious( + { ...input, timezone }, + false, + timezone, + )(overviewService.getMapData.bind(overviewService)); + return current; }), });