fix(dashboard): share overview (all widgets didnt work)

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-05 21:24:37 +00:00
parent 2d0478d626
commit 7a88b262c0
15 changed files with 820 additions and 136 deletions

View File

@@ -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,
}),
);

View File

@@ -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;

View File

@@ -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<string, number>();
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 (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">Loading map...</div>
</div>
);
}
if (query.isError) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">Error loading map</div>
</div>
);
}
if (!query.data || mapData.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">No data available</div>
</div>
);
}
return (
<div className="h-full w-full" style={{ minHeight: 300 }}>
<AutoSizer disableHeight>
{({ width }) => (
<WorldMap
onClickFunction={(event) => {
if (event.countryCode) {
setFilter('country', event.countryCode);
}
}}
size={width}
data={mapData}
color={'var(--chart-0)'}
borderColor={'var(--foreground)'}
/>
)}
</AutoSizer>
</div>
);
}

View File

@@ -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',
)}
>
<OverviewLiveHistogram projectId={projectId} />
<OverviewLiveHistogram projectId={projectId} shareId={shareId} />
</div>
</div>
@@ -344,7 +349,7 @@ function Chart({
onAnimationEnd={handleAnimationEnd}
/>
<Tooltip />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
<YAxis {...yAxisProps} width={25} />
<XAxis {...xAxisProps} />
<CartesianGrid
strokeDasharray="3 3"
@@ -471,7 +476,12 @@ function Chart({
<Tooltip />
<YAxis
{...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
domain={[
0,
activeMetric.key === 'bounce_rate'
? 100
: (dataMax: number) => Math.max(dataMax, 1),
]}
width={25}
/>
<YAxis
@@ -480,14 +490,18 @@ function Chart({
orientation="right"
domain={[
0,
data.reduce(
(max, item) => 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}
/>
<XAxis {...xAxisProps} />
<XAxis {...xAxisProps} padding={{ left: 10, right: 10 }} />
<CartesianGrid
strokeDasharray="3 3"
@@ -523,19 +537,11 @@ function Chart({
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2}
isAnimationActive={false}
dot={
data.length > 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,
}

View File

@@ -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,

View File

@@ -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"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.breakdownProperty) {
setFilter(widget.meta.breakdownProperty, name);
if (widget.meta?.type === 'linkOut') {
setFilter('properties.href', name);
} else {
setFilter('name', name);
}

View File

@@ -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<IChartType>('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) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
{...{
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
interval: interval,
range: range,
previous: previous,
}}
/>
<OverviewMap projectId={projectId} shareId={shareId} />
</WidgetBody>
</Widget>
</>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}),
);

View File

@@ -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 (
<SheetContent className="[&>button.absolute]:hidden">

View File

@@ -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() {
<OverviewRange />
</div>
<div className="flex gap-2">
<LiveCounter projectId={projectId} />
<LiveCounter projectId={projectId} shareId={shareId} />
</div>
</div>
<OverviewFiltersButtons />
</div>
</div>
<div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4">
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
<OverviewMetrics projectId={projectId} shareId={shareId} />
<OverviewTopSources projectId={projectId} shareId={shareId} />
<OverviewTopPages projectId={projectId} shareId={shareId} />
<OverviewTopDevices projectId={projectId} shareId={shareId} />
<OverviewTopEvents projectId={projectId} shareId={shareId} />
<OverviewTopGeo projectId={projectId} shareId={shareId} />
<LazyComponent className="col-span-6">
<OverviewUserJourney projectId={projectId} shareId={shareId} />
</LazyComponent>
</div>
</div>
);

View File

@@ -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<typeof zGetUserJourneyInput> & {
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<typeof zGetTopEventsInput> & {
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<typeof zGetTopLinkOutInput> & {
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<typeof zGetMapDataInput> & {
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<Array<{ name: string; count: number }>> {
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<Array<{ href: string; count: number }>> {
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);

View File

@@ -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<string, string | undefined>;
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,
};
}

View File

@@ -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;
}),
});