fix(dashboard): share overview (all widgets didnt work)
This commit is contained in:
@@ -10,11 +10,12 @@ import { AnimatedNumber } from '../animated-number';
|
|||||||
|
|
||||||
export interface LiveCounterProps {
|
export interface LiveCounterProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIFTEEN_SECONDS = 1000 * 30;
|
const FIFTEEN_SECONDS = 1000 * 30;
|
||||||
|
|
||||||
export function LiveCounter({ projectId }: LiveCounterProps) {
|
export function LiveCounter({ projectId, shareId }: LiveCounterProps) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const counter = useDebounceState(0, 1000);
|
const counter = useDebounceState(0, 1000);
|
||||||
@@ -22,6 +23,7 @@ export function LiveCounter({ projectId }: LiveCounterProps) {
|
|||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.liveVisitors.queryOptions({
|
trpc.overview.liveVisitors.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,18 @@ import {
|
|||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
interface OverviewLiveHistogramProps {
|
interface OverviewLiveHistogramProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewLiveHistogram({
|
export function OverviewLiveHistogram({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
}: OverviewLiveHistogramProps) {
|
}: OverviewLiveHistogramProps) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
|
||||||
// Use the new liveData endpoint instead of chart props
|
// Use the new liveData endpoint instead of chart props
|
||||||
const { data: liveData, isLoading } = useQuery(
|
const { data: liveData, isLoading } = useQuery(
|
||||||
trpc.overview.liveData.queryOptions({ projectId }),
|
trpc.overview.liveData.queryOptions({ projectId, shareId }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalSessions = liveData?.totalSessions ?? 0;
|
const totalSessions = liveData?.totalSessions ?? 0;
|
||||||
|
|||||||
90
apps/start/src/components/overview/overview-map.tsx
Normal file
90
apps/start/src/components/overview/overview-map.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import { OverviewMetricCard } from './overview-metric-card';
|
|||||||
|
|
||||||
interface OverviewMetricsProps {
|
interface OverviewMetricsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TITLES = [
|
const TITLES = [
|
||||||
@@ -83,7 +84,10 @@ const TITLES = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
export default function OverviewMetrics({
|
||||||
|
projectId,
|
||||||
|
shareId,
|
||||||
|
}: OverviewMetricsProps) {
|
||||||
const { range, interval, metric, setMetric, startDate, endDate } =
|
const { range, interval, metric, setMetric, startDate, endDate } =
|
||||||
useOverviewOptions();
|
useOverviewOptions();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
@@ -93,6 +97,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
const overviewQuery = useQuery(
|
const overviewQuery = useQuery(
|
||||||
trpc.overview.stats.queryOptions({
|
trpc.overview.stats.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
interval,
|
interval,
|
||||||
filters,
|
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',
|
'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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,7 +349,7 @@ function Chart({
|
|||||||
onAnimationEnd={handleAnimationEnd}
|
onAnimationEnd={handleAnimationEnd}
|
||||||
/>
|
/>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
|
<YAxis {...yAxisProps} width={25} />
|
||||||
<XAxis {...xAxisProps} />
|
<XAxis {...xAxisProps} />
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
@@ -471,7 +476,12 @@ function Chart({
|
|||||||
<Tooltip />
|
<Tooltip />
|
||||||
<YAxis
|
<YAxis
|
||||||
{...yAxisProps}
|
{...yAxisProps}
|
||||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
domain={[
|
||||||
|
0,
|
||||||
|
activeMetric.key === 'bounce_rate'
|
||||||
|
? 100
|
||||||
|
: (dataMax: number) => Math.max(dataMax, 1),
|
||||||
|
]}
|
||||||
width={25}
|
width={25}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
@@ -480,14 +490,18 @@ function Chart({
|
|||||||
orientation="right"
|
orientation="right"
|
||||||
domain={[
|
domain={[
|
||||||
0,
|
0,
|
||||||
|
Math.max(
|
||||||
data.reduce(
|
data.reduce(
|
||||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||||
0,
|
0,
|
||||||
) * 2,
|
) * 1.2,
|
||||||
|
1,
|
||||||
|
),
|
||||||
]}
|
]}
|
||||||
width={30}
|
width={30}
|
||||||
|
allowDataOverflow={false}
|
||||||
/>
|
/>
|
||||||
<XAxis {...xAxisProps} />
|
<XAxis {...xAxisProps} padding={{ left: 10, right: 10 }} />
|
||||||
|
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
@@ -523,19 +537,11 @@ function Chart({
|
|||||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
dot={
|
dot={false}
|
||||||
data.length > 90
|
|
||||||
? false
|
|
||||||
: {
|
|
||||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
|
||||||
fill: 'transparent',
|
|
||||||
strokeWidth: 1.5,
|
|
||||||
r: 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeDot={{
|
activeDot={{
|
||||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||||
fill: 'transparent',
|
fill: 'var(--def-100)',
|
||||||
|
fillOpacity: 1,
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
r: 3,
|
r: 3,
|
||||||
}}
|
}}
|
||||||
@@ -581,7 +587,8 @@ function Chart({
|
|||||||
? false
|
? false
|
||||||
: {
|
: {
|
||||||
stroke: getChartColor(0),
|
stroke: getChartColor(0),
|
||||||
fill: 'transparent',
|
fill: 'var(--def-100)',
|
||||||
|
fillOpacity: 1,
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
r: 3,
|
r: 3,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import { useOverviewWidget } from './useOverviewWidget';
|
|||||||
|
|
||||||
interface OverviewTopDevicesProps {
|
interface OverviewTopDevicesProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
export default function OverviewTopDevices({
|
export default function OverviewTopDevices({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
}: OverviewTopDevicesProps) {
|
}: OverviewTopDevicesProps) {
|
||||||
const { interval, range, previous, startDate, endDate } =
|
const { interval, range, previous, startDate, endDate } =
|
||||||
useOverviewOptions();
|
useOverviewOptions();
|
||||||
@@ -325,6 +327,7 @@ export default function OverviewTopDevices({
|
|||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topGeneric.queryOptions({
|
trpc.overview.topGeneric.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
filters,
|
filters,
|
||||||
column: widget.key,
|
column: widget.key,
|
||||||
@@ -337,6 +340,7 @@ export default function OverviewTopDevices({
|
|||||||
trpc.overview.topGenericSeries.queryOptions(
|
trpc.overview.topGenericSeries.queryOptions(
|
||||||
{
|
{
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
filters,
|
filters,
|
||||||
column: widget.key,
|
column: widget.key,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { IReportInput } from '@openpanel/validation';
|
|
||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
@@ -17,17 +15,18 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
|||||||
|
|
||||||
export interface OverviewTopEventsProps {
|
export interface OverviewTopEventsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverviewTopEvents({
|
export default function OverviewTopEvents({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
}: OverviewTopEventsProps) {
|
}: OverviewTopEventsProps) {
|
||||||
const { interval, range, previous, startDate, endDate } =
|
const { range, startDate, endDate } = useOverviewOptions();
|
||||||
useOverviewOptions();
|
|
||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { data: conversions } = useQuery(
|
const { data: conversions } = useQuery(
|
||||||
trpc.event.conversionNames.queryOptions({ projectId }),
|
trpc.overview.topConversions.queryOptions({ projectId, shareId }),
|
||||||
);
|
);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
@@ -36,15 +35,7 @@ export default function OverviewTopEvents({
|
|||||||
title: 'Events',
|
title: 'Events',
|
||||||
btn: 'Events',
|
btn: 'Events',
|
||||||
meta: {
|
meta: {
|
||||||
filters: [
|
type: 'events' as const,
|
||||||
{
|
|
||||||
id: 'ex_session',
|
|
||||||
name: 'name',
|
|
||||||
operator: 'isNot',
|
|
||||||
value: ['session_start', 'session_end', 'screen_view'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
eventName: '*',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
conversions: {
|
conversions: {
|
||||||
@@ -52,69 +43,84 @@ export default function OverviewTopEvents({
|
|||||||
btn: 'Conversions',
|
btn: 'Conversions',
|
||||||
hide: !conversions || conversions.length === 0,
|
hide: !conversions || conversions.length === 0,
|
||||||
meta: {
|
meta: {
|
||||||
filters: [
|
type: 'conversions' as const,
|
||||||
{
|
|
||||||
id: 'conversion',
|
|
||||||
name: 'name',
|
|
||||||
operator: 'is',
|
|
||||||
value: conversions?.map((c) => c.name) ?? [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
eventName: '*',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
link_out: {
|
link_out: {
|
||||||
title: 'Link out',
|
title: 'Link out',
|
||||||
btn: 'Link out',
|
btn: 'Link out',
|
||||||
meta: {
|
meta: {
|
||||||
filters: [],
|
type: 'linkOut' as const,
|
||||||
eventName: 'link_out',
|
|
||||||
breakdownProperty: 'properties.href',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const report: IReportInput = useMemo(
|
// Use different endpoints based on widget type
|
||||||
() => ({
|
const eventsQuery = useQuery(
|
||||||
limit: 1000,
|
trpc.overview.topEvents.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
|
range,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
series: [
|
filters,
|
||||||
{
|
excludeEvents:
|
||||||
type: 'event' as const,
|
widget.meta?.type === 'events'
|
||||||
segment: 'event' as const,
|
? ['session_start', 'session_end', 'screen_view']
|
||||||
filters: [...filters, ...(widget.meta?.filters ?? [])],
|
: undefined,
|
||||||
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,
|
|
||||||
}),
|
}),
|
||||||
[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(() => {
|
const tableData: EventTableItem[] = useMemo(() => {
|
||||||
if (!query.data?.series) return [];
|
// For link out, use href as name
|
||||||
|
if (widget.meta?.type === 'linkOut') {
|
||||||
return query.data.series.map((serie) => ({
|
if (!linkOutQuery.data) return [];
|
||||||
id: serie.id,
|
return linkOutQuery.data.map((item) => ({
|
||||||
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
|
id: item.href,
|
||||||
count: serie.metrics.sum,
|
name: item.href,
|
||||||
|
count: item.count,
|
||||||
}));
|
}));
|
||||||
}, [query.data]);
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}));
|
||||||
|
}, [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(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
@@ -150,14 +156,14 @@ export default function OverviewTopEvents({
|
|||||||
className="border-b-0 pb-2"
|
className="border-b-0 pb-2"
|
||||||
/>
|
/>
|
||||||
<WidgetBody className="p-0">
|
<WidgetBody className="p-0">
|
||||||
{query.isLoading ? (
|
{isLoading ? (
|
||||||
<OverviewWidgetTableLoading />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableEvents
|
<OverviewWidgetTableEvents
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
onItemClick={(name) => {
|
onItemClick={(name) => {
|
||||||
if (widget.meta?.breakdownProperty) {
|
if (widget.meta?.type === 'linkOut') {
|
||||||
setFilter(widget.meta.breakdownProperty, name);
|
setFilter('properties.href', name);
|
||||||
} else {
|
} else {
|
||||||
setFilter('name', name);
|
setFilter('name', name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import { countries } from '@/translations/countries';
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
import { ReportChart } from '../report-chart';
|
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { ReportChartShortcut } from '../report-chart/shortcut';
|
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
OverviewLineChart,
|
OverviewLineChart,
|
||||||
OverviewLineChartLoading,
|
OverviewLineChartLoading,
|
||||||
} from './overview-line-chart';
|
} from './overview-line-chart';
|
||||||
|
import { OverviewMap } from './overview-map';
|
||||||
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
|
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
|
||||||
import {
|
import {
|
||||||
WidgetFooter,
|
WidgetFooter,
|
||||||
@@ -34,8 +33,12 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
|||||||
|
|
||||||
interface OverviewTopGeoProps {
|
interface OverviewTopGeoProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
export default function OverviewTopGeo({
|
||||||
|
projectId,
|
||||||
|
shareId,
|
||||||
|
}: OverviewTopGeoProps) {
|
||||||
const { interval, range, previous, startDate, endDate } =
|
const { interval, range, previous, startDate, endDate } =
|
||||||
useOverviewOptions();
|
useOverviewOptions();
|
||||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||||
@@ -63,6 +66,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topGeneric.queryOptions({
|
trpc.overview.topGeneric.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
filters,
|
filters,
|
||||||
column: widget.key,
|
column: widget.key,
|
||||||
@@ -75,6 +79,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
trpc.overview.topGenericSeries.queryOptions(
|
trpc.overview.topGenericSeries.queryOptions(
|
||||||
{
|
{
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
filters,
|
filters,
|
||||||
column: widget.key,
|
column: widget.key,
|
||||||
@@ -211,32 +216,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
<div className="title">Map</div>
|
<div className="title">Map</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ReportChartShortcut
|
<OverviewMap projectId={projectId} shareId={shareId} />
|
||||||
{...{
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
|||||||
|
|
||||||
interface OverviewTopPagesProps {
|
interface OverviewTopPagesProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
export default function OverviewTopPages({
|
||||||
|
projectId,
|
||||||
|
shareId,
|
||||||
|
}: OverviewTopPagesProps) {
|
||||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
||||||
@@ -56,6 +60,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topPages.queryOptions({
|
trpc.overview.topPages.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
filters,
|
filters,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
|
|||||||
|
|
||||||
interface OverviewTopSourcesProps {
|
interface OverviewTopSourcesProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
export default function OverviewTopSources({
|
export default function OverviewTopSources({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
}: OverviewTopSourcesProps) {
|
}: OverviewTopSourcesProps) {
|
||||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
@@ -71,6 +73,7 @@ export default function OverviewTopSources({
|
|||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topGeneric.queryOptions({
|
trpc.overview.topGeneric.queryOptions({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
filters,
|
filters,
|
||||||
column: widget.key,
|
column: widget.key,
|
||||||
@@ -83,6 +86,7 @@ export default function OverviewTopSources({
|
|||||||
trpc.overview.topGenericSeries.queryOptions(
|
trpc.overview.topGenericSeries.queryOptions(
|
||||||
{
|
{
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
range,
|
range,
|
||||||
filters,
|
filters,
|
||||||
column: widget.key,
|
column: widget.key,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useOverviewOptions } from './useOverviewOptions';
|
|||||||
|
|
||||||
interface OverviewUserJourneyProps {
|
interface OverviewUserJourneyProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
||||||
@@ -159,6 +160,7 @@ function SankeyPortalTooltip({
|
|||||||
|
|
||||||
export default function OverviewUserJourney({
|
export default function OverviewUserJourney({
|
||||||
projectId,
|
projectId,
|
||||||
|
shareId,
|
||||||
}: OverviewUserJourneyProps) {
|
}: OverviewUserJourneyProps) {
|
||||||
const { range, startDate, endDate } = useOverviewOptions();
|
const { range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
@@ -177,6 +179,7 @@ export default function OverviewUserJourney({
|
|||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
steps: steps ?? 5,
|
steps: steps ?? 5,
|
||||||
|
shareId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function OverviewFilters({
|
|||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||||
const [event, setEvent] = useEventQueryNamesFilter(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);
|
const selectedFilters = filters.filter((filter) => filter.value[0] !== null);
|
||||||
return (
|
return (
|
||||||
<SheetContent className="[&>button.absolute]:hidden">
|
<SheetContent className="[&>button.absolute]:hidden">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import { LazyComponent } from '@/components/lazy-component';
|
||||||
import { LoginNavbar } from '@/components/login-navbar';
|
import { LoginNavbar } from '@/components/login-navbar';
|
||||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||||
import { LiveCounter } from '@/components/overview/live-counter';
|
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 OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
|
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||||
@@ -110,19 +112,22 @@ function RouteComponent() {
|
|||||||
<OverviewRange />
|
<OverviewRange />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<LiveCounter projectId={projectId} />
|
<LiveCounter projectId={projectId} shareId={shareId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OverviewFiltersButtons />
|
<OverviewFiltersButtons />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4">
|
<div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4">
|
||||||
<OverviewMetrics projectId={projectId} />
|
<OverviewMetrics projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopSources projectId={projectId} />
|
<OverviewTopSources projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopPages projectId={projectId} />
|
<OverviewTopPages projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopDevices projectId={projectId} />
|
<OverviewTopDevices projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopEvents projectId={projectId} />
|
<OverviewTopEvents projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopGeo projectId={projectId} />
|
<OverviewTopGeo projectId={projectId} shareId={shareId} />
|
||||||
|
<LazyComponent className="col-span-6">
|
||||||
|
<OverviewUserJourney projectId={projectId} shareId={shareId} />
|
||||||
|
</LazyComponent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { chartColors } from '@openpanel/constants';
|
|||||||
import { getCache } from '@openpanel/redis';
|
import { getCache } from '@openpanel/redis';
|
||||||
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
import { getEventFiltersWhereClause } from './chart.service';
|
import { getEventFiltersWhereClause, getSelectPropertyKey } from './chart.service';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const ROLLUP_DATE_PREFIX = '1970-01-01';
|
const ROLLUP_DATE_PREFIX = '1970-01-01';
|
||||||
@@ -127,12 +128,53 @@ export type IGetUserJourneyInput = z.infer<typeof zGetUserJourneyInput> & {
|
|||||||
timezone: string;
|
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 {
|
export class OverviewService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
private isRollupRow(date: string): boolean {
|
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) {
|
private getFillConfig(interval: string, startDate: string, endDate: string) {
|
||||||
@@ -1223,6 +1265,150 @@ export class OverviewService {
|
|||||||
links: filteredLinks,
|
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);
|
export const overviewService = new OverviewService(ch);
|
||||||
|
|||||||
@@ -213,3 +213,67 @@ export async function validateShareAccess(
|
|||||||
|
|
||||||
throw new Error('Share not found');
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ import {
|
|||||||
eventBuffer,
|
eventBuffer,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
|
getConversionEventNames,
|
||||||
getOrganizationSubscriptionChartEndDate,
|
getOrganizationSubscriptionChartEndDate,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
overviewService,
|
overviewService,
|
||||||
|
validateOverviewShareAccess,
|
||||||
|
zGetMapDataInput,
|
||||||
zGetMetricsInput,
|
zGetMetricsInput,
|
||||||
|
zGetTopEventsInput,
|
||||||
zGetTopGenericInput,
|
zGetTopGenericInput,
|
||||||
zGetTopGenericSeriesInput,
|
zGetTopGenericSeriesInput,
|
||||||
|
zGetTopLinkOutInput,
|
||||||
zGetTopPagesInput,
|
zGetTopPagesInput,
|
||||||
zGetUserJourneyInput,
|
zGetUserJourneyInput,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { type IChartRange, zRange } from '@openpanel/validation';
|
import { type IChartRange, zRange } from '@openpanel/validation';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { getProjectAccess } from '../access';
|
||||||
|
import { TRPCAccessError } from '../errors';
|
||||||
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
|
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
const cacher = cacheMiddleware((input, opts) => {
|
const cacher = cacheMiddleware((input, opts) => {
|
||||||
@@ -87,15 +94,58 @@ function getCurrentAndPrevious<
|
|||||||
|
|
||||||
export const overviewRouter = createTRPCRouter({
|
export const overviewRouter = createTRPCRouter({
|
||||||
liveVisitors: publicProcedure
|
liveVisitors: publicProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string(), shareId: z.string().optional() }))
|
||||||
.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return eventBuffer.getActiveVisitorCount(input.projectId);
|
return eventBuffer.getActiveVisitorCount(input.projectId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
liveData: publicProcedure
|
liveData: publicProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string(), shareId: z.string().optional() }))
|
||||||
.use(cacher)
|
.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 { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
|
||||||
// Get total unique sessions in the last 30 minutes
|
// Get total unique sessions in the last 30 minutes
|
||||||
@@ -212,10 +262,32 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
startDate: z.string().nullish(),
|
startDate: z.string().nullish(),
|
||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
|
shareId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(cacher)
|
.use(cacher)
|
||||||
.query(async ({ ctx, input }) => {
|
.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 { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { current, previous } = await getCurrentAndPrevious(
|
const { current, previous } = await getCurrentAndPrevious(
|
||||||
{ ...input, timezone },
|
{ ...input, timezone },
|
||||||
@@ -258,10 +330,32 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
mode: z.enum(['page', 'entry', 'exit', 'bot']),
|
mode: z.enum(['page', 'entry', 'exit', 'bot']),
|
||||||
|
shareId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(cacher)
|
.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 { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { current } = await getCurrentAndPrevious(
|
const { current } = await getCurrentAndPrevious(
|
||||||
{ ...input },
|
{ ...input },
|
||||||
@@ -292,10 +386,34 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
startDate: z.string().nullish(),
|
startDate: z.string().nullish(),
|
||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
|
shareId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(cacher)
|
.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 { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { current } = await getCurrentAndPrevious(
|
const { current } = await getCurrentAndPrevious(
|
||||||
{ ...input, timezone },
|
{ ...input, timezone },
|
||||||
@@ -308,14 +426,38 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
|
|
||||||
topGenericSeries: publicProcedure
|
topGenericSeries: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
zGetTopGenericSeriesInput.omit({ startDate: true, endDate: true }).extend({
|
zGetTopGenericSeriesInput
|
||||||
|
.omit({ startDate: true, endDate: true })
|
||||||
|
.extend({
|
||||||
startDate: z.string().nullish(),
|
startDate: z.string().nullish(),
|
||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
|
shareId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(cacher)
|
.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 { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { current } = await getCurrentAndPrevious(
|
const { current } = await getCurrentAndPrevious(
|
||||||
{ ...input, timezone },
|
{ ...input, timezone },
|
||||||
@@ -333,10 +475,32 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
steps: z.number().min(2).max(10).default(5).optional(),
|
steps: z.number().min(2).max(10).default(5).optional(),
|
||||||
|
shareId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.use(cacher)
|
.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 { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { current } = await getCurrentAndPrevious(
|
const { current } = await getCurrentAndPrevious(
|
||||||
{ ...input, timezone },
|
{ ...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;
|
return current;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user