From f98e68397978fd37032c535f4b99276cb9e1ef92 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 21 Oct 2025 17:20:35 -0700 Subject: [PATCH 1/4] add zod schema to realtime endpoint --- src/app/api/realtime/[websiteId]/route.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts index 61751350..054a1241 100644 --- a/src/app/api/realtime/[websiteId]/route.ts +++ b/src/app/api/realtime/[websiteId]/route.ts @@ -1,15 +1,21 @@ -import { json, unauthorized } from '@/lib/response'; -import { getRealtimeData } from '@/queries/sql'; -import { canViewWebsite } from '@/permissions'; -import { startOfMinute, subMinutes } from 'date-fns'; import { REALTIME_RANGE } from '@/lib/constants'; -import { parseRequest, getQueryFilters } from '@/lib/request'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { timezoneParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getRealtimeData } from '@/queries/sql'; +import { startOfMinute, subMinutes } from 'date-fns'; +import z from 'zod'; export async function GET( request: Request, { params }: { params: Promise<{ websiteId: string }> }, ) { - const { auth, query, error } = await parseRequest(request); + const schema = z.object({ + timezone: timezoneParam, + }); + + const { auth, query, error } = await parseRequest(request, schema); if (error) { return error(); From 533a42eb2e9be2393b9c04355052362f020664bf Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 21 Oct 2025 19:54:50 -0700 Subject: [PATCH 2/4] clean-up session api endpoints and queries --- .../session-data/properties/route.ts | 6 ++-- .../[websiteId]/session-data/values/route.ts | 2 ++ .../[websiteId]/sessions/weekly/route.ts | 7 ++--- .../queries/useSessionDataPropertiesQuery.ts | 16 ++++++++-- .../queries/useSessionDataValuesQuery.ts | 16 ++++++++-- .../queries/useWebsiteSessionStatsQuery.ts | 7 +++-- .../hooks/queries/useWebsiteSessionsQuery.ts | 18 ++++++++---- .../sql/sessions/getSessionDataProperties.ts | 29 +++++++------------ .../sql/sessions/getSessionDataValues.ts | 1 + .../sql/sessions/getWebsiteSessions.ts | 5 ++-- 10 files changed, 64 insertions(+), 43 deletions(-) diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts index 704ae519..64328ce5 100644 --- a/src/app/api/websites/[websiteId]/session-data/properties/route.ts +++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts @@ -3,6 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { unauthorized, json } from '@/lib/response'; import { canViewWebsite } from '@/permissions'; import { getSessionDataProperties } from '@/queries/sql'; +import { filterParams } from '@/lib/schema'; export async function GET( request: Request, @@ -11,7 +12,7 @@ export async function GET( const schema = z.object({ startAt: z.coerce.number().int(), endAt: z.coerce.number().int(), - propertyName: z.string().optional(), + ...filterParams, }); const { auth, query, error } = await parseRequest(request, schema); @@ -26,10 +27,9 @@ export async function GET( return unauthorized(); } - const { propertyName } = query; const filters = await getQueryFilters(query, websiteId); - const data = await getSessionDataProperties(websiteId, { ...filters, propertyName }); + const data = await getSessionDataProperties(websiteId, filters); return json(data); } diff --git a/src/app/api/websites/[websiteId]/session-data/values/route.ts b/src/app/api/websites/[websiteId]/session-data/values/route.ts index f95f5d43..75464ba4 100644 --- a/src/app/api/websites/[websiteId]/session-data/values/route.ts +++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts @@ -3,6 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { getSessionDataValues } from '@/queries/sql'; import { z } from 'zod'; +import { filterParams } from '@/lib/schema'; export async function GET( request: Request, @@ -12,6 +13,7 @@ export async function GET( startAt: z.coerce.number().int(), endAt: z.coerce.number().int(), propertyName: z.string().optional(), + ...filterParams, }); const { auth, query, error } = await parseRequest(request, schema); diff --git a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts index af1a1f60..f3d17f94 100644 --- a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts +++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts @@ -1,9 +1,9 @@ -import { z } from 'zod'; import { getQueryFilters, parseRequest } from '@/lib/request'; -import { unauthorized, json } from '@/lib/response'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam } from '@/lib/schema'; import { canViewWebsite } from '@/permissions'; -import { filterParams, pagingParams, timezoneParam } from '@/lib/schema'; import { getWeeklyTraffic } from '@/queries/sql'; +import { z } from 'zod'; export async function GET( request: Request, @@ -14,7 +14,6 @@ export async function GET( endAt: z.coerce.number().int(), timezone: timezoneParam, ...filterParams, - ...pagingParams, }); const { auth, query, error } = await parseRequest(request, schema); diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts index 2bb13950..975c728a 100644 --- a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts +++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts @@ -5,12 +5,22 @@ import { ReactQueryOptions } from '@/lib/types'; export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); - const date = useDateParameters(); + const { startAt, endAt, unit, timezone } = useDateParameters(); const filters = useFilterParameters(); return useQuery({ - queryKey: ['websites:session-data:properties', { websiteId, ...date, ...filters }], - queryFn: () => get(`/websites/${websiteId}/session-data/properties`, { ...date, ...filters }), + queryKey: [ + 'websites:session-data:properties', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/session-data/properties`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), enabled: !!websiteId, ...options, }); diff --git a/src/components/hooks/queries/useSessionDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataValuesQuery.ts index 05373d21..7df35938 100644 --- a/src/components/hooks/queries/useSessionDataValuesQuery.ts +++ b/src/components/hooks/queries/useSessionDataValuesQuery.ts @@ -9,13 +9,23 @@ export function useSessionDataValuesQuery( options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); - const date = useDateParameters(); + const { startAt, endAt, unit, timezone } = useDateParameters(); const filters = useFilterParameters(); return useQuery({ - queryKey: ['websites:session-data:values', { websiteId, propertyName, ...date, ...filters }], + queryKey: [ + 'websites:session-data:values', + { websiteId, propertyName, startAt, endAt, unit, timezone, ...filters }, + ], queryFn: () => - get(`/websites/${websiteId}/session-data/values`, { ...date, ...filters, propertyName }), + get(`/websites/${websiteId}/session-data/values`, { + startAt, + endAt, + unit, + timezone, + ...filters, + propertyName, + }), enabled: !!(websiteId && propertyName), ...options, }); diff --git a/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts index 9c9aaddc..82a7c05f 100644 --- a/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts +++ b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts @@ -4,12 +4,13 @@ import { useDateParameters } from '../useDateParameters'; export function useWebsiteSessionStatsQuery(websiteId: string, options?: Record) { const { get, useQuery } = useApi(); - const date = useDateParameters(); + const { startAt, endAt, unit, timezone } = useDateParameters(); const filters = useFilterParameters(); return useQuery({ - queryKey: ['sessions:stats', { websiteId, ...date, ...filters }], - queryFn: () => get(`/websites/${websiteId}/sessions/stats`, { ...date, ...filters }), + queryKey: ['sessions:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/sessions/stats`, { startAt, endAt, unit, timezone, ...filters }), enabled: !!websiteId, ...options, }); diff --git a/src/components/hooks/queries/useWebsiteSessionsQuery.ts b/src/components/hooks/queries/useWebsiteSessionsQuery.ts index a3de19ca..31906be9 100644 --- a/src/components/hooks/queries/useWebsiteSessionsQuery.ts +++ b/src/components/hooks/queries/useWebsiteSessionsQuery.ts @@ -1,8 +1,8 @@ import { useApi } from '../useApi'; -import { usePagedQuery } from '../usePagedQuery'; -import { useModified } from '../useModified'; -import { useFilterParameters } from '../useFilterParameters'; import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; export function useWebsiteSessionsQuery( websiteId: string, @@ -10,14 +10,20 @@ export function useWebsiteSessionsQuery( ) { const { get } = useApi(); const { modified } = useModified(`sessions`); - const date = useDateParameters(); + const { startAt, endAt, unit, timezone } = useDateParameters(); const filters = useFilterParameters(); return usePagedQuery({ - queryKey: ['sessions', { websiteId, modified, ...params, ...date, ...filters }], + queryKey: [ + 'sessions', + { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, + ], queryFn: pageParams => { return get(`/websites/${websiteId}/sessions`, { - ...date, + startAt, + endAt, + unit, + timezone, ...filters, ...pageParams, ...params, diff --git a/src/queries/sql/sessions/getSessionDataProperties.ts b/src/queries/sql/sessions/getSessionDataProperties.ts index 134cf720..78e4cba4 100644 --- a/src/queries/sql/sessions/getSessionDataProperties.ts +++ b/src/queries/sql/sessions/getSessionDataProperties.ts @@ -1,12 +1,12 @@ -import prisma from '@/lib/prisma'; import clickhouse from '@/lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; import { QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getSessionDataProperties'; export async function getSessionDataProperties( - ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] + ...args: [websiteId: string, filters: QueryFilters] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -14,17 +14,12 @@ export async function getSessionDataProperties( }); } -async function relationalQuery( - websiteId: string, - filters: QueryFilters & { propertyName?: string }, -) { +async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( - { ...filters, websiteId }, - { - columns: { propertyName: 'data_key' }, - }, - ); + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); return rawQuery( ` @@ -50,15 +45,10 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, - filters: QueryFilters & { propertyName?: string }, + filters: QueryFilters, ): Promise<{ propertyName: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, cohortQuery, queryParams } = parseFilters( - { ...filters, websiteId }, - { - columns: { propertyName: 'data_key' }, - }, - ); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -69,6 +59,7 @@ async function clickhouseQuery( ${cohortQuery} join session_data final on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} where website_event.website_id = {websiteId:UUID} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} and session_data.data_key != '' diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts index 8da12669..efc85090 100644 --- a/src/queries/sql/sessions/getSessionDataValues.ts +++ b/src/queries/sql/sessions/getSessionDataValues.ts @@ -69,6 +69,7 @@ async function clickhouseQuery( ${cohortQuery} join session_data final on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} where website_event.website_id = {websiteId:UUID} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} and session_data.data_key = {propertyName:String} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts index 44b52d8e..302deca6 100644 --- a/src/queries/sql/sessions/getWebsiteSessions.ts +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -98,6 +98,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { select session_id as id, website_id as websiteId, + hostname, browser, os, device, @@ -117,7 +118,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${dateQuery} ${filterQuery} ${searchQuery} - group by session_id, website_id, browser, os, device, screen, language, country, region, city + group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city order by lastAt desc `; } else { @@ -125,7 +126,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { select session_id as id, website_id as websiteId, - hostname, + arrayFirst(x -> 1, hostname) hostname, browser, os, device, From 2e1a5b444a33906f58f1a8c4f4eb20d9e35cc8eb Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 21 Oct 2025 21:12:22 -0700 Subject: [PATCH 3/4] revenue, events, and session activity optimization --- src/queries/sql/events/getWebsiteEvents.ts | 5 +++- src/queries/sql/reports/getRevenue.ts | 27 +++++++++---------- .../sql/sessions/getSessionActivity.ts | 16 ++++++----- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts index d4625c2b..52113daf 100644 --- a/src/queries/sql/events/getWebsiteEvents.ts +++ b/src/queries/sql/events/getWebsiteEvents.ts @@ -96,7 +96,10 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { page_title as pageTitle, event_type as eventType, event_name as eventName, - event_id IN (SELECT event_id FROM event_data where website_id = {websiteId:UUID}) as hasData + event_id IN (select event_id + from event_data + where website_id = {websiteId:UUID} + ${dateQuery}) as hasData from website_event ${cohortQuery} where website_id = {websiteId:UUID} diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts index 890717d9..e13106ce 100644 --- a/src/queries/sql/reports/getRevenue.ts +++ b/src/queries/sql/reports/getRevenue.ts @@ -134,6 +134,15 @@ async function clickhouseQuery( currency, }); + const joinQuery = filterQuery + ? `join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}` + : ''; + const chart = await rawQuery< { x: string; @@ -147,12 +156,7 @@ async function clickhouseQuery( ${getDateSQL('website_revenue.created_at', unit, timezone)} t, sum(website_revenue.revenue) y from website_revenue - join website_event - on website_event.website_id = website_revenue.website_id - and website_event.session_id = website_revenue.session_id - and website_event.event_id = website_revenue.event_id - and website_event.website_id = {websiteId:UUID} - and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${joinQuery} ${cohortQuery} where website_revenue.website_id = {websiteId:UUID} and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} @@ -175,13 +179,13 @@ async function clickhouseQuery( website_event.country as name, sum(website_revenue.revenue) as value from website_revenue - join website_event + join website_event on website_event.website_id = website_revenue.website_id and website_event.session_id = website_revenue.session_id and website_event.event_id = website_revenue.event_id and website_event.website_id = {websiteId:UUID} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} - ${cohortQuery} + ${cohortQuery} where website_revenue.website_id = {websiteId:UUID} and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} and website_revenue.currency = {currency:String} @@ -203,12 +207,7 @@ async function clickhouseQuery( uniqExact(website_revenue.event_id) as count, uniqExact(website_revenue.session_id) as unique_count from website_revenue - join website_event - on website_event.website_id = website_revenue.website_id - and website_event.session_id = website_revenue.session_id - and website_event.event_id = website_revenue.event_id - and website_event.website_id = {websiteId:UUID} - and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${joinQuery} ${cohortQuery} where website_revenue.website_id = {websiteId:UUID} and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} diff --git a/src/queries/sql/sessions/getSessionActivity.ts b/src/queries/sql/sessions/getSessionActivity.ts index 9f1be24a..6b79338e 100644 --- a/src/queries/sql/sessions/getSessionActivity.ts +++ b/src/queries/sql/sessions/getSessionActivity.ts @@ -57,12 +57,16 @@ async function clickhouseQuery(websiteId: string, sessionId: string, filters: Qu event_type as eventType, event_name as eventName, visit_id as visitId, - event_id IN (SELECT event_id FROM event_data where website_id = {websiteId:UUID} and session_id = {sessionId:UUID}) AS hasData - from website_event e - where e.website_id = {websiteId:UUID} - and e.session_id = {sessionId:UUID} - and e.created_at between {startDate:DateTime64} and {endDate:DateTime64} - order by e.created_at desc + event_id IN (select event_id + from event_data + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}) AS hasData + from website_event + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + order by created_at desc limit 500 `, { websiteId, sessionId, startDate, endDate }, From f5bf148b2b2f675d43b5940444bed9a970530e52 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 21 Oct 2025 21:59:20 -0700 Subject: [PATCH 4/4] add close button on session modal --- .../[websiteId]/sessions/SessionModal.tsx | 8 +++-- .../[websiteId]/sessions/SessionProfile.tsx | 32 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx index e751743b..6c34b97b 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx @@ -29,9 +29,11 @@ export function SessionModal({ websiteId, ...props }: SessionModalProps) { > - - - + {({ close }) => ( + + close()} /> + + )} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx index f8c01137..e83c7957 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx @@ -1,4 +1,14 @@ -import { TextField, Row, Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen'; +import { + TextField, + Row, + Column, + Tabs, + TabList, + Tab, + TabPanel, + Button, + Icon, +} from '@umami/react-zen'; import { Avatar } from '@/components/common/Avatar'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useMessages, useWebsiteSessionQuery } from '@/components/hooks'; @@ -6,8 +16,17 @@ import { SessionActivity } from './SessionActivity'; import { SessionData } from './SessionData'; import { SessionInfo } from './SessionInfo'; import { SessionStats } from './SessionStats'; +import { X } from 'lucide-react'; -export function SessionProfile({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { +export function SessionProfile({ + websiteId, + sessionId, + onClose, +}: { + websiteId: string; + sessionId: string; + onClose?: () => void; +}) { const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId); const { formatMessage, labels } = useMessages(); @@ -21,6 +40,15 @@ export function SessionProfile({ websiteId, sessionId }: { websiteId: string; se > {data && ( + {onClose && ( + + + + )}