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

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