diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 0e530f93..1e03175a 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -4,6 +4,7 @@ import { getProfileById, getSalts, replayBuffer, + upsertGroup, upsertProfile, } from '@openpanel/db'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; @@ -14,6 +15,7 @@ import { import { getRedisCache } from '@openpanel/redis'; import { type IDecrementPayload, + type IGroupPayload, type IIdentifyPayload, type IIncrementPayload, type IReplayPayload, @@ -218,6 +220,7 @@ async function handleTrack( headers, event: { ...payload, + groups: payload.groups ?? [], timestamp: timestamp.value, isTimestampFromThePast: timestamp.isFromPast, }, @@ -333,6 +336,20 @@ async function handleReplay( await replayBuffer.add(row); } +async function handleGroup( + payload: IGroupPayload, + context: TrackContext +): Promise { + const { id, type, name, properties = {} } = payload; + await upsertGroup({ + id, + projectId: context.projectId, + type, + name, + properties, + }); +} + export async function handler( request: FastifyRequest<{ Body: ITrackHandlerPayload; @@ -381,6 +398,9 @@ export async function handler( case 'replay': await handleReplay(validatedBody.payload, context); break; + case 'group': + await handleGroup(validatedBody.payload, context); + break; default: return reply.status(400).send({ status: 400, diff --git a/apps/start/src/components/report/ReportSegment.tsx b/apps/start/src/components/report/ReportSegment.tsx index 0928f780..c320164e 100644 --- a/apps/start/src/components/report/ReportSegment.tsx +++ b/apps/start/src/components/report/ReportSegment.tsx @@ -1,5 +1,8 @@ +import { chartSegments } from '@openpanel/constants'; +import { type IChartEventSegment, mapKeys } from '@openpanel/validation'; import { ActivityIcon, + Building2Icon, ClockIcon, EqualApproximatelyIcon, type LucideIcon, @@ -10,10 +13,7 @@ import { UserCheckIcon, UsersIcon, } from 'lucide-react'; - -import { chartSegments } from '@openpanel/constants'; -import { type IChartEventSegment, mapKeys } from '@openpanel/validation'; - +import { Button } from '../ui/button'; import { DropdownMenu, DropdownMenuContent, @@ -25,7 +25,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/utils/cn'; -import { Button } from '../ui/button'; interface ReportChartTypeProps { className?: string; @@ -46,6 +45,7 @@ export function ReportSegment({ event: ActivityIcon, user: UsersIcon, session: ClockIcon, + group: Building2Icon, user_average: UserCheck2Icon, one_event_per_user: UserCheckIcon, property_sum: SigmaIcon, @@ -58,9 +58,9 @@ export function ReportSegment({ @@ -74,13 +74,13 @@ export function ReportSegment({ const Icon = Icons[item.value]; return ( onChange(item.value)} - className="group" > {item.label} - + ); diff --git a/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx b/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx index 843fc3fa..eaef982c 100644 --- a/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx +++ b/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx @@ -1,3 +1,14 @@ +import type { IChartEvent } from '@openpanel/validation'; +import { useQuery } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + ArrowLeftIcon, + Building2Icon, + DatabaseIcon, + UserIcon, +} from 'lucide-react'; +import VirtualList from 'rc-virtual-list'; +import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -10,11 +21,7 @@ import { import { Input } from '@/components/ui/input'; import { useAppParams } from '@/hooks/use-app-params'; import { useEventProperties } from '@/hooks/use-event-properties'; -import type { IChartEvent } from '@openpanel/validation'; -import { AnimatePresence, motion } from 'framer-motion'; -import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react'; -import VirtualList from 'rc-virtual-list'; -import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; +import { useTRPC } from '@/integrations/trpc/react'; interface PropertiesComboboxProps { event?: IChartEvent; @@ -40,15 +47,15 @@ function SearchHeader({ return (
{!!onBack && ( - )} onSearch(e.target.value)} placeholder="Search" value={value} - onChange={(e) => onSearch(e.target.value)} - autoFocus />
); @@ -62,18 +69,24 @@ export function PropertiesCombobox({ exclude = [], }: PropertiesComboboxProps) { const { projectId } = useAppParams(); + const trpc = useTRPC(); const [open, setOpen] = useState(false); const properties = useEventProperties({ event: event?.name, projectId, }); - const [state, setState] = useState<'index' | 'event' | 'profile'>('index'); + const groupPropertiesQuery = useQuery( + trpc.group.properties.queryOptions({ projectId }) + ); + const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>( + 'index' + ); const [search, setSearch] = useState(''); const [direction, setDirection] = useState<'forward' | 'backward'>('forward'); useEffect(() => { if (!open) { - setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile'); + setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index'); } }, [open, mode]); @@ -86,11 +99,21 @@ export function PropertiesCombobox({ }); }; - // Mock data for the lists + // Fixed group properties: name, type, plus dynamic property keys + const groupActions = [ + { value: 'group.name', label: 'name', description: 'group' }, + { value: 'group.type', label: 'type', description: 'group' }, + ...(groupPropertiesQuery.data ?? []).map((key) => ({ + value: `group.properties.${key}`, + label: key, + description: 'group.properties', + })), + ].filter((a) => shouldShowProperty(a.value)); + const profileActions = properties .filter( (property) => - property.startsWith('profile') && shouldShowProperty(property), + property.startsWith('profile') && shouldShowProperty(property) ) .map((property) => ({ value: property, @@ -100,7 +123,7 @@ export function PropertiesCombobox({ const eventActions = properties .filter( (property) => - !property.startsWith('profile') && shouldShowProperty(property), + !property.startsWith('profile') && shouldShowProperty(property) ) .map((property) => ({ value: property, @@ -108,7 +131,9 @@ export function PropertiesCombobox({ description: property.split('.').slice(0, -1).join('.'), })); - const handleStateChange = (newState: 'index' | 'event' | 'profile') => { + const handleStateChange = ( + newState: 'index' | 'event' | 'profile' | 'group' + ) => { setDirection(newState === 'index' ? 'backward' : 'forward'); setState(newState); }; @@ -135,7 +160,7 @@ export function PropertiesCombobox({ }} > Event properties - + Profile properties - + + + { + e.preventDefault(); + handleStateChange('group'); + }} + > + Group properties + ); @@ -155,7 +190,7 @@ export function PropertiesCombobox({ const filteredActions = eventActions.filter( (action) => action.label.toLowerCase().includes(search.toLowerCase()) || - action.description.toLowerCase().includes(search.toLowerCase()), + action.description.toLowerCase().includes(search.toLowerCase()) ); return ( @@ -169,20 +204,20 @@ export function PropertiesCombobox({ /> {(action) => ( handleSelect(action)} >
{action.label}
-
+
{action.description}
@@ -196,7 +231,7 @@ export function PropertiesCombobox({ const filteredActions = profileActions.filter( (action) => action.label.toLowerCase().includes(search.toLowerCase()) || - action.description.toLowerCase().includes(search.toLowerCase()), + action.description.toLowerCase().includes(search.toLowerCase()) ); return ( @@ -208,20 +243,59 @@ export function PropertiesCombobox({ /> {(action) => ( handleSelect(action)} >
{action.label}
-
+
+ {action.description} +
+ + )} + +
+ ); + }; + + const renderGroup = () => { + const filteredActions = groupActions.filter( + (action) => + action.label.toLowerCase().includes(search.toLowerCase()) || + action.description.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ handleStateChange('index')} + onSearch={setSearch} + value={search} + /> + + + {(action) => ( + handleSelect(action)} + > +
{action.label}
+
{action.description}
@@ -233,20 +307,20 @@ export function PropertiesCombobox({ return ( { setOpen(open); }} + open={open} > {children(setOpen)} - - + + {state === 'index' && ( {renderIndex()} @@ -254,10 +328,10 @@ export function PropertiesCombobox({ )} {state === 'event' && ( {renderEvent()} @@ -265,15 +339,26 @@ export function PropertiesCombobox({ )} {state === 'profile' && ( {renderProfile()} )} + {state === 'group' && ( + + {renderGroup()} + + )} diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 09737962..1f381605 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { BellIcon, BookOpenIcon, + Building2Icon, ChartLineIcon, ChevronDownIcon, CogIcon, @@ -62,6 +63,7 @@ export default function SidebarProjectMenu({ +
Manage
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 8742b541..e5d54379 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime' import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages' import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights' +import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups' import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards' import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat' import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index' @@ -63,6 +64,7 @@ import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId' import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs' import { Route as AppOrganizationIdProjectIdNotificationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs' +import { Route as AppOrganizationIdProjectIdGroupsGroupIdRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId' import { Route as AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs' import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId' import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index' @@ -350,6 +352,12 @@ const AppOrganizationIdProjectIdInsightsRoute = path: '/insights', getParentRoute: () => AppOrganizationIdProjectIdRoute, } as any) +const AppOrganizationIdProjectIdGroupsRoute = + AppOrganizationIdProjectIdGroupsRouteImport.update({ + id: '/groups', + path: '/groups', + getParentRoute: () => AppOrganizationIdProjectIdRoute, + } as any) const AppOrganizationIdProjectIdDashboardsRoute = AppOrganizationIdProjectIdDashboardsRouteImport.update({ id: '/dashboards', @@ -443,6 +451,12 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute = id: '/_tabs', getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute, } as any) +const AppOrganizationIdProjectIdGroupsGroupIdRoute = + AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({ + id: '/groups_/$groupId', + path: '/groups/$groupId', + getParentRoute: () => AppOrganizationIdProjectIdRoute, + } as any) const AppOrganizationIdProjectIdEventsTabsRoute = AppOrganizationIdProjectIdEventsTabsRouteImport.update({ id: '/_tabs', @@ -615,6 +629,7 @@ export interface FileRoutesByFullPath { '/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute + '/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute '/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute @@ -630,6 +645,7 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute '/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren + '/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute '/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren '/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute @@ -688,6 +704,7 @@ export interface FileRoutesByTo { '/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute + '/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute '/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute @@ -703,6 +720,7 @@ export interface FileRoutesByTo { '/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute '/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute + '/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute '/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute @@ -760,6 +778,7 @@ export interface FileRoutesById { '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute + '/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute '/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute @@ -779,6 +798,7 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren '/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren + '/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute '/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren '/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren '/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren @@ -845,6 +865,7 @@ export interface FileRouteTypes { | '/$organizationId/' | '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/dashboards' + | '/$organizationId/$projectId/groups' | '/$organizationId/$projectId/insights' | '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/realtime' @@ -860,6 +881,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/' | '/$organizationId/$projectId/dashboards/$dashboardId' | '/$organizationId/$projectId/events' + | '/$organizationId/$projectId/groups/$groupId' | '/$organizationId/$projectId/notifications' | '/$organizationId/$projectId/profiles' | '/$organizationId/$projectId/reports/$reportId' @@ -918,6 +940,7 @@ export interface FileRouteTypes { | '/$organizationId' | '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/dashboards' + | '/$organizationId/$projectId/groups' | '/$organizationId/$projectId/insights' | '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/realtime' @@ -933,6 +956,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId' | '/$organizationId/$projectId/dashboards/$dashboardId' | '/$organizationId/$projectId/events' + | '/$organizationId/$projectId/groups/$groupId' | '/$organizationId/$projectId/notifications' | '/$organizationId/$projectId/profiles' | '/$organizationId/$projectId/reports/$reportId' @@ -989,6 +1013,7 @@ export interface FileRouteTypes { | '/_app/$organizationId/' | '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/dashboards' + | '/_app/$organizationId/$projectId/groups' | '/_app/$organizationId/$projectId/insights' | '/_app/$organizationId/$projectId/pages' | '/_app/$organizationId/$projectId/realtime' @@ -1008,6 +1033,7 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/dashboards_/$dashboardId' | '/_app/$organizationId/$projectId/events' | '/_app/$organizationId/$projectId/events/_tabs' + | '/_app/$organizationId/$projectId/groups_/$groupId' | '/_app/$organizationId/$projectId/notifications' | '/_app/$organizationId/$projectId/notifications/_tabs' | '/_app/$organizationId/$projectId/profiles' @@ -1378,6 +1404,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport parentRoute: typeof AppOrganizationIdProjectIdRoute } + '/_app/$organizationId/$projectId/groups': { + id: '/_app/$organizationId/$projectId/groups' + path: '/groups' + fullPath: '/$organizationId/$projectId/groups' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsRouteImport + parentRoute: typeof AppOrganizationIdProjectIdRoute + } '/_app/$organizationId/$projectId/dashboards': { id: '/_app/$organizationId/$projectId/dashboards' path: '/dashboards' @@ -1490,6 +1523,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute } + '/_app/$organizationId/$projectId/groups_/$groupId': { + id: '/_app/$organizationId/$projectId/groups_/$groupId' + path: '/groups/$groupId' + fullPath: '/$organizationId/$projectId/groups/$groupId' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport + parentRoute: typeof AppOrganizationIdProjectIdRoute + } '/_app/$organizationId/$projectId/events/_tabs': { id: '/_app/$organizationId/$projectId/events/_tabs' path: '/events' @@ -1875,6 +1915,7 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren = interface AppOrganizationIdProjectIdRouteChildren { AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute + AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute @@ -1885,6 +1926,7 @@ interface AppOrganizationIdProjectIdRouteChildren { AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren + AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute @@ -1897,6 +1939,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute, AppOrganizationIdProjectIdDashboardsRoute: AppOrganizationIdProjectIdDashboardsRoute, + AppOrganizationIdProjectIdGroupsRoute: + AppOrganizationIdProjectIdGroupsRoute, AppOrganizationIdProjectIdInsightsRoute: AppOrganizationIdProjectIdInsightsRoute, AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute, @@ -1914,6 +1958,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh AppOrganizationIdProjectIdDashboardsDashboardIdRoute, AppOrganizationIdProjectIdEventsRoute: AppOrganizationIdProjectIdEventsRouteWithChildren, + AppOrganizationIdProjectIdGroupsGroupIdRoute: + AppOrganizationIdProjectIdGroupsGroupIdRoute, AppOrganizationIdProjectIdNotificationsRoute: AppOrganizationIdProjectIdNotificationsRouteWithChildren, AppOrganizationIdProjectIdProfilesRoute: diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx new file mode 100644 index 00000000..b3180b10 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx @@ -0,0 +1,146 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { createFileRoute, Link } from '@tanstack/react-router'; +import { Building2Icon } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useSearchQueryState } from '@/hooks/use-search-query-state'; +import { useTRPC } from '@/integrations/trpc/react'; +import { formatDateTime } from '@/utils/date'; +import { createProjectTitle } from '@/utils/title'; + +export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')( + { + component: Component, + head: () => ({ + meta: [{ title: createProjectTitle('Groups') }], + }), + } +); + +function Component() { + const { projectId, organizationId } = Route.useParams(); + const trpc = useTRPC(); + const { search, setSearch, debouncedSearch } = useSearchQueryState(); + const [typeFilter, setTypeFilter] = useQueryState( + 'type', + parseAsString.withDefault('') + ); + + const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId })); + + const groupsQuery = useQuery( + trpc.group.list.queryOptions( + { + projectId, + search: debouncedSearch || undefined, + type: typeFilter || undefined, + take: 100, + }, + { placeholderData: keepPreviousData } + ) + ); + + const groups = groupsQuery.data?.data ?? []; + const types = typesQuery.data ?? []; + + return ( + + + +
+ setSearch(e.target.value)} + placeholder="Search groups..." + value={search} + /> + {types.length > 0 && ( + + )} +
+ + {groups.length === 0 ? ( +
+ +

No groups found

+
+ ) : ( +
+ + + + + + + + + + + {groups.map((group) => ( + + + + + + + ))} + +
+ Name + + ID + + Type + + Created +
+ + {group.name} + + + {group.id} + + {group.type} + + {formatDateTime(new Date(group.createdAt))} +
+
+ )} +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx new file mode 100644 index 00000000..4edde3ed --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx @@ -0,0 +1,244 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, Link } from '@tanstack/react-router'; +import { Building2Icon, UsersIcon } from 'lucide-react'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; +import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { ProfileActivity } from '@/components/profiles/profile-activity'; +import { Badge } from '@/components/ui/badge'; +import { KeyValueGrid } from '@/components/ui/key-value-grid'; +import { Widget, WidgetBody } from '@/components/widget'; +import { useTRPC } from '@/integrations/trpc/react'; +import { formatDateTime } from '@/utils/date'; +import { createProjectTitle } from '@/utils/title'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/groups_/$groupId' +)({ + component: Component, + loader: async ({ context, params }) => { + await Promise.all([ + context.queryClient.prefetchQuery( + context.trpc.group.byId.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + context.queryClient.prefetchQuery( + context.trpc.group.metrics.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + context.queryClient.prefetchQuery( + context.trpc.group.activity.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + context.queryClient.prefetchQuery( + context.trpc.group.members.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + ]); + }, + pendingComponent: FullPageLoadingState, + head: () => ({ + meta: [{ title: createProjectTitle('Group') }], + }), +}); + +function Component() { + const { projectId, organizationId, groupId } = Route.useParams(); + const trpc = useTRPC(); + + const group = useSuspenseQuery( + trpc.group.byId.queryOptions({ id: groupId, projectId }) + ); + + const metrics = useSuspenseQuery( + trpc.group.metrics.queryOptions({ id: groupId, projectId }) + ); + + const activity = useSuspenseQuery( + trpc.group.activity.queryOptions({ id: groupId, projectId }) + ); + + const members = useSuspenseQuery( + trpc.group.members.queryOptions({ id: groupId, projectId }) + ); + + const g = group.data; + const m = metrics.data?.[0]; + + if (!g) { + return ( + +
+ +

Group not found

+
+
+ ); + } + + const properties = g.properties as Record; + + return ( + + + + {g.name} + + {g.type} + +
+ } + > +

{g.id}

+ + +
+ {/* Metrics */} + {m && ( +
+
+ + + + +
+
+ )} + + {/* Properties */} +
+ + +
Group Information
+
+ v !== undefined && v !== '') + .map(([k, v]) => ({ + name: k, + value: String(v), + })), + ]} + /> +
+
+ + {/* Activity heatmap */} +
+ +
+ + {/* Members */} +
+ + + Members + + + {members.data.length === 0 ? ( +

+ No members found +

+ ) : ( + + + + + + + + + {members.data.map((member) => ( + + + + + ))} + +
+ Profile + + Events +
+ + {member.profileId} + + + {member.eventCount} +
+ )} +
+
+
+
+ + ); +} diff --git a/apps/start/src/utils/title.ts b/apps/start/src/utils/title.ts index 47b43fb8..1d310735 100644 --- a/apps/start/src/utils/title.ts +++ b/apps/start/src/utils/title.ts @@ -10,7 +10,7 @@ const BASE_TITLE = 'OpenPanel.dev'; export function createTitle( pageTitle: string, section?: string, - baseTitle = BASE_TITLE, + baseTitle = BASE_TITLE ): string { const parts = [pageTitle]; if (section) { @@ -25,7 +25,7 @@ export function createTitle( */ export function createOrganizationTitle( pageTitle: string, - organizationName?: string, + organizationName?: string ): string { if (organizationName) { return createTitle(pageTitle, organizationName); @@ -39,7 +39,7 @@ export function createOrganizationTitle( export function createProjectTitle( pageTitle: string, projectName?: string, - organizationName?: string, + organizationName?: string ): string { const parts = [pageTitle]; if (projectName) { @@ -59,7 +59,7 @@ export function createEntityTitle( entityName: string, entityType: string, projectName?: string, - organizationName?: string, + organizationName?: string ): string { const parts = [entityName, entityType]; if (projectName) { @@ -95,6 +95,9 @@ export const PAGE_TITLES = { PROFILES: 'Profiles', PROFILE_EVENTS: 'Profile events', PROFILE_DETAILS: 'Profile details', + // Groups + GROUPS: 'Groups', + GROUP_DETAILS: 'Group details', // Sub-sections CONVERSIONS: 'Conversions', diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts index 1f15e5d3..30a49f37 100644 --- a/apps/worker/src/jobs/events.incoming-event.ts +++ b/apps/worker/src/jobs/events.incoming-event.ts @@ -134,6 +134,7 @@ export async function incomingEvent( __hash: hash, __query: query, }), + groups: body.groups ?? [], createdAt, duration: 0, sdkName, diff --git a/biome.json b/biome.json index eb6da5b2..af16f524 100644 --- a/biome.json +++ b/biome.json @@ -63,7 +63,8 @@ }, "performance": { "noDelete": "off", - "noAccumulatingSpread": "off" + "noAccumulatingSpread": "off", + "noBarrelFile": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 8169f0bd..5d4760ba 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -113,6 +113,7 @@ export const chartSegments = { event: 'All events', user: 'Unique users', session: 'Unique sessions', + group: 'Unique groups', user_average: 'Average users', one_event_per_user: 'One event per user', property_sum: 'Sum of property', @@ -195,7 +196,7 @@ export const metrics = { } as const; export function isMinuteIntervalEnabledByRange( - range: keyof typeof timeWindows, + range: keyof typeof timeWindows ) { return range === '30min' || range === 'lastHour'; } @@ -210,7 +211,7 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeWindows) { } export function getDefaultIntervalByRange( - range: keyof typeof timeWindows, + range: keyof typeof timeWindows ): keyof typeof intervals { if (range === '30min' || range === 'lastHour') { return 'minute'; @@ -231,7 +232,7 @@ export function getDefaultIntervalByRange( export function getDefaultIntervalByDates( startDate: string | null, - endDate: string | null, + endDate: string | null ): null | keyof typeof intervals { if (startDate && endDate) { if (isSameDay(startDate, endDate)) { diff --git a/packages/db/code-migrations/11-add-groups.ts b/packages/db/code-migrations/11-add-groups.ts new file mode 100644 index 00000000..6d9f2e69 --- /dev/null +++ b/packages/db/code-migrations/11-add-groups.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + addColumns, + runClickhouseMigrationCommands, +} from '../src/clickhouse/migration'; +import { getIsCluster } from './helpers'; + +export async function up() { + const isClustered = getIsCluster(); + + const databaseUrl = process.env.DATABASE_URL ?? ''; + // Parse postgres connection string: postgresql://user:password@host:port/dbname + const match = databaseUrl.match( + /postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+?)(\?.*)?$/ + ); + + if (!match) { + throw new Error(`Could not parse DATABASE_URL: ${databaseUrl}`); + } + + const [, pgUser, pgPassword, pgHost, pgPort, pgDb] = match; + + const dictSql = `CREATE DICTIONARY IF NOT EXISTS groups_dict +( + id String, + project_id String, + type String, + name String, + properties String +) +PRIMARY KEY id, project_id +SOURCE(POSTGRESQL( + host '${pgHost}' + port ${pgPort} + user '${pgUser}' + password '${pgPassword}' + db '${pgDb}' + table 'groups' +)) +LIFETIME(MIN 300 MAX 600) +LAYOUT(COMPLEX_KEY_HASHED())`; + + const sqls: string[] = [ + ...addColumns( + 'events', + ['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3))'], + isClustered + ), + dictSql, + ]; + + fs.writeFileSync( + path.join(import.meta.filename.replace('.ts', '.sql')), + sqls + .map((sql) => + sql + .trim() + .replace(/;$/, '') + .replace(/\n{2,}/g, '\n') + .concat(';') + ) + .join('\n\n---\n\n') + ); + + if (!process.argv.includes('--dry')) { + await runClickhouseMigrationCommands(sqls); + } +} diff --git a/packages/db/index.ts b/packages/db/index.ts index b71c3d3a..2eb494b8 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,35 +1,37 @@ -export * from './src/prisma-client'; -export * from './src/clickhouse/client'; -export * from './src/sql-builder'; -export * from './src/services/chart.service'; -export * from './src/engine'; -export * from './src/services/clients.service'; -export * from './src/services/dashboard.service'; -export * from './src/services/event.service'; -export * from './src/services/organization.service'; -export * from './src/services/profile.service'; -export * from './src/services/project.service'; -export * from './src/services/reports.service'; -export * from './src/services/salt.service'; -export * from './src/services/share.service'; -export * from './src/services/session.service'; -export * from './src/services/funnel.service'; -export * from './src/services/conversion.service'; -export * from './src/services/sankey.service'; -export * from './src/services/user.service'; -export * from './src/services/reference.service'; -export * from './src/services/id.service'; -export * from './src/services/retention.service'; -export * from './src/services/notification.service'; -export * from './src/services/access.service'; -export * from './src/services/delete.service'; export * from './src/buffers'; -export * from './src/types'; +export * from './src/clickhouse/client'; export * from './src/clickhouse/query-builder'; +export * from './src/encryption'; +export * from './src/engine'; +export * from './src/engine'; +export * from './src/gsc'; +export * from './src/prisma-client'; +export * from './src/services/access.service'; +export * from './src/services/chart.service'; +export * from './src/services/clients.service'; +export * from './src/services/conversion.service'; +export * from './src/services/dashboard.service'; +export * from './src/services/delete.service'; +export * from './src/services/event.service'; +export * from './src/services/funnel.service'; +export * from './src/services/group.service'; +export * from './src/services/id.service'; export * from './src/services/import.service'; +export * from './src/services/insights'; +export * from './src/services/notification.service'; +export * from './src/services/organization.service'; export * from './src/services/overview.service'; export * from './src/services/pages.service'; -export * from './src/services/insights'; +export * from './src/services/profile.service'; +export * from './src/services/project.service'; +export * from './src/services/reference.service'; +export * from './src/services/reports.service'; +export * from './src/services/retention.service'; +export * from './src/services/salt.service'; +export * from './src/services/sankey.service'; +export * from './src/services/session.service'; +export * from './src/services/share.service'; +export * from './src/services/user.service'; export * from './src/session-context'; -export * from './src/gsc'; -export * from './src/encryption'; +export * from './src/sql-builder'; +export * from './src/types'; diff --git a/packages/db/prisma/migrations/20260218164139_groups/migration.sql b/packages/db/prisma/migrations/20260218164139_groups/migration.sql new file mode 100644 index 00000000..e0b7190d --- /dev/null +++ b/packages/db/prisma/migrations/20260218164139_groups/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "public"."groups" ( + "id" TEXT NOT NULL DEFAULT '', + "projectId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "properties" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "groups_pkey" PRIMARY KEY ("projectId","id") +); + +-- AddForeignKey +ALTER TABLE "public"."groups" ADD CONSTRAINT "groups_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8bf72e14..d6146174 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -199,6 +199,7 @@ model Project { meta EventMeta[] references Reference[] access ProjectAccess[] + groups Group[] notificationRules NotificationRule[] notifications Notification[] @@ -215,6 +216,20 @@ model Project { @@map("projects") } +model Group { + id String @default("") + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + type String + name String + properties Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@id([projectId, id]) + @@map("groups") +} + enum AccessLevel { read write diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index 3a0ba20a..eb8bfd5a 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -61,6 +61,7 @@ export const TABLE_NAMES = { gsc_daily: 'gsc_daily', gsc_pages_daily: 'gsc_pages_daily', gsc_queries_daily: 'gsc_queries_daily', + groups: 'groups', }; /** diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 568ea788..711caf97 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -1,20 +1,18 @@ -import sqlstring from 'sqlstring'; - import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common'; import type { IChartEventFilter, - IReportInput, IChartRange, IGetChartDataInput, + IReportInput, } from '@openpanel/validation'; - -import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client'; +import sqlstring from 'sqlstring'; +import { formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client'; import { createSqlBuilder } from '../sql-builder'; export function transformPropertyKey(property: string) { const propertyPatterns = ['properties', 'profile.properties']; const match = propertyPatterns.find((pattern) => - property.startsWith(`${pattern}.`), + property.startsWith(`${pattern}.`) ); if (!match) { @@ -32,21 +30,49 @@ export function transformPropertyKey(property: string) { return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`; } -export function getSelectPropertyKey(property: string) { +// Returns a SQL expression for a group property using dictGet +// property format: "group.name", "group.type", "group.properties.plan" +export function getGroupPropertySql( + property: string, + projectId: string +): string { + const withoutPrefix = property.replace(/^group\./, ''); + if (withoutPrefix === 'name') { + return `dictGet('${TABLE_NAMES.groups_dict}', 'name', tuple(_group_id, ${sqlstring.escape(projectId)}))`; + } + if (withoutPrefix === 'type') { + return `dictGet('${TABLE_NAMES.groups_dict}', 'type', tuple(_group_id, ${sqlstring.escape(projectId)}))`; + } + if (withoutPrefix.startsWith('properties.')) { + const propKey = withoutPrefix.replace(/^properties\./, ''); + // properties is stored as JSON string in dict; use JSONExtractString + return `JSONExtractString(dictGet('${TABLE_NAMES.groups_dict}', 'properties', tuple(_group_id, ${sqlstring.escape(projectId)})), ${sqlstring.escape(propKey)})`; + } + return '_group_id'; +} + +export function getSelectPropertyKey(property: string, projectId?: string) { if (property === 'has_profile') { return `if(profile_id != device_id, 'true', 'false')`; } + // Handle group properties — requires ARRAY JOIN to be present in query + if (property.startsWith('group.') && projectId) { + return getGroupPropertySql(property, projectId); + } + const propertyPatterns = ['properties', 'profile.properties']; const match = propertyPatterns.find((pattern) => - property.startsWith(`${pattern}.`), + property.startsWith(`${pattern}.`) ); - if (!match) return property; + if (!match) { + return property; + } if (property.includes('*')) { return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape( - transformPropertyKey(property), + transformPropertyKey(property) )})))`; } @@ -78,7 +104,7 @@ export function getChartSql({ with: addCte, } = createSqlBuilder(); - sb.where = getEventFiltersWhereClause(event.filters); + sb.where = getEventFiltersWhereClause(event.filters, projectId); sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`; if (event.name !== '*') { @@ -89,11 +115,23 @@ export function getChartSql({ } const anyFilterOnProfile = event.filters.some((filter) => - filter.name.startsWith('profile.'), + filter.name.startsWith('profile.') ); const anyBreakdownOnProfile = breakdowns.some((breakdown) => - breakdown.name.startsWith('profile.'), + breakdown.name.startsWith('profile.') ); + const anyFilterOnGroup = event.filters.some((filter) => + filter.name.startsWith('group.') + ); + const anyBreakdownOnGroup = breakdowns.some((breakdown) => + breakdown.name.startsWith('group.') + ); + const needsGroupArrayJoin = + anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group'; + + if (needsGroupArrayJoin) { + sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + } // Build WHERE clause without the bar filter (for use in subqueries and CTEs) // Define this early so we can use it in CTE definitions @@ -178,8 +216,8 @@ export function getChartSql({ addCte( 'profile', `SELECT ${selectFields.join(', ')} - FROM ${TABLE_NAMES.profiles} FINAL - WHERE project_id = ${sqlstring.escape(projectId)}`, + FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)}` ); // Use the CTE reference in the main query @@ -228,28 +266,33 @@ export function getChartSql({ // Use CTE to define top breakdown values once, then reference in WHERE clause if (breakdowns.length > 0 && limit) { const breakdownSelects = breakdowns - .map((b) => getSelectPropertyKey(b.name)) + .map((b) => getSelectPropertyKey(b.name, projectId)) .join(', '); + const groupArrayJoinClause = needsGroupArrayJoin + ? 'ARRAY JOIN groups AS _group_id' + : ''; + // Add top_breakdowns CTE using the builder addCte( 'top_breakdowns', `SELECT ${breakdownSelects} FROM ${TABLE_NAMES.events} e - ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()} + ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()} GROUP BY ${breakdownSelects} ORDER BY count(*) DESC - LIMIT ${limit}`, + LIMIT ${limit}` ); // Filter main query to only include top breakdown values - sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`; + sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`; } breakdowns.forEach((breakdown, index) => { // Breakdowns start at label_1 (label_0 is reserved for event name) const key = `label_${index + 1}`; - sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`; + sb.select[key] = + `${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`; sb.groupBy[key] = `${key}`; }); @@ -261,6 +304,10 @@ export function getChartSql({ sb.select.count = 'countDistinct(session_id) as count'; } + if (event.segment === 'group') { + sb.select.count = 'countDistinct(_group_id) as count'; + } + if (event.segment === 'user_average') { sb.select.count = 'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count'; @@ -289,7 +336,7 @@ export function getChartSql({ sb.from = `( SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( sb.where, - ' AND ', + ' AND ' )} ORDER BY profile_id, created_at DESC ) as subQuery`; @@ -308,7 +355,7 @@ export function getChartSql({ // Since outer query groups by label_X, we reference those in the correlation const breakdownMatches = breakdowns .map((b, index) => { - const propertyKey = getSelectPropertyKey(b.name); + const propertyKey = getSelectPropertyKey(b.name, projectId); // Correlate: match the property expression with outer query's label_X value // ClickHouse allows referencing outer query columns in correlated subqueries return `${propertyKey} = label_${index + 1}`; @@ -359,7 +406,7 @@ export function getAggregateChartSql({ }) { const { sb, join, getJoins, with: addCte, getSql } = createSqlBuilder(); - sb.where = getEventFiltersWhereClause(event.filters); + sb.where = getEventFiltersWhereClause(event.filters, projectId); sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`; if (event.name !== '*') { @@ -370,11 +417,23 @@ export function getAggregateChartSql({ } const anyFilterOnProfile = event.filters.some((filter) => - filter.name.startsWith('profile.'), + filter.name.startsWith('profile.') ); const anyBreakdownOnProfile = breakdowns.some((breakdown) => - breakdown.name.startsWith('profile.'), + breakdown.name.startsWith('profile.') ); + const anyFilterOnGroup = event.filters.some((filter) => + filter.name.startsWith('group.') + ); + const anyBreakdownOnGroup = breakdowns.some((breakdown) => + breakdown.name.startsWith('group.') + ); + const needsGroupArrayJoin = + anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group'; + + if (needsGroupArrayJoin) { + sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + } // Build WHERE clause without the bar filter (for use in subqueries and CTEs) const getWhereWithoutBar = () => { @@ -455,8 +514,8 @@ export function getAggregateChartSql({ addCte( 'profile', `SELECT ${selectFields.join(', ')} - FROM ${TABLE_NAMES.profiles} FINAL - WHERE project_id = ${sqlstring.escape(projectId)}`, + FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)}` ); sb.joins.profiles = profilesJoinRef; @@ -478,28 +537,33 @@ export function getAggregateChartSql({ // Use CTE to define top breakdown values once, then reference in WHERE clause if (breakdowns.length > 0 && limit) { const breakdownSelects = breakdowns - .map((b) => getSelectPropertyKey(b.name)) + .map((b) => getSelectPropertyKey(b.name, projectId)) .join(', '); + const groupArrayJoinClause = needsGroupArrayJoin + ? 'ARRAY JOIN groups AS _group_id' + : ''; + addCte( 'top_breakdowns', `SELECT ${breakdownSelects} FROM ${TABLE_NAMES.events} e - ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()} + ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()} GROUP BY ${breakdownSelects} ORDER BY count(*) DESC - LIMIT ${limit}`, + LIMIT ${limit}` ); // Filter main query to only include top breakdown values - sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`; + sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`; } // Add breakdowns to SELECT and GROUP BY breakdowns.forEach((breakdown, index) => { // Breakdowns start at label_1 (label_0 is reserved for event name) const key = `label_${index + 1}`; - sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`; + sb.select[key] = + `${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`; sb.groupBy[key] = `${key}`; }); @@ -518,6 +582,10 @@ export function getAggregateChartSql({ sb.select.count = 'countDistinct(session_id) as count'; } + if (event.segment === 'group') { + sb.select.count = 'countDistinct(_group_id) as count'; + } + if (event.segment === 'user_average') { sb.select.count = 'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count'; @@ -531,7 +599,7 @@ export function getAggregateChartSql({ }[event.segment as string]; if (mathFunction && event.property) { - const propertyKey = getSelectPropertyKey(event.property); + const propertyKey = getSelectPropertyKey(event.property, projectId); if (isNumericColumn(event.property)) { sb.select.count = `${mathFunction}(${propertyKey}) as count`; @@ -546,7 +614,7 @@ export function getAggregateChartSql({ sb.from = `( SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( sb.where, - ' AND ', + ' AND ' )} ORDER BY profile_id, created_at DESC ) as subQuery`; @@ -579,7 +647,10 @@ function isNumericColumn(columnName: string): boolean { return numericColumns.includes(columnName); } -export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { +export function getEventFiltersWhereClause( + filters: IChartEventFilter[], + projectId?: string +) { const where: Record = {}; filters.forEach((filter, index) => { const id = `f${index}`; @@ -602,6 +673,67 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { return; } + // Handle group. prefixed filters using dictGet (requires ARRAY JOIN in query) + if (name.startsWith('group.') && projectId) { + const whereFrom = getGroupPropertySql(name, projectId); + switch (operator) { + case 'is': { + if (value.length === 1) { + where[id] = + `${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`; + } else { + where[id] = + `${whereFrom} IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`; + } + break; + } + case 'isNot': { + if (value.length === 1) { + where[id] = + `${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`; + } else { + where[id] = + `${whereFrom} NOT IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`; + } + break; + } + case 'contains': { + where[id] = + `(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`; + break; + } + case 'doesNotContain': { + where[id] = + `(${value.map((val) => `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`; + break; + } + case 'startsWith': { + where[id] = + `(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`).join(' OR ')})`; + break; + } + case 'endsWith': { + where[id] = + `(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`).join(' OR ')})`; + break; + } + case 'isNull': { + where[id] = `(${whereFrom} = '' OR ${whereFrom} IS NULL)`; + break; + } + case 'isNotNull': { + where[id] = `(${whereFrom} != '' AND ${whereFrom} IS NOT NULL)`; + break; + } + case 'regex': { + where[id] = + `(${value.map((val) => `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`).join(' OR ')})`; + break; + } + } + return; + } + if ( name.startsWith('properties.') || name.startsWith('profile.properties.') @@ -616,15 +748,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map((val) => `x = ${sqlstring.escape(String(val).trim())}`) .join(' OR ')}, ${whereFrom})`; + } else if (value.length === 1) { + where[id] = + `${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`; } else { - if (value.length === 1) { - where[id] = - `${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`; - } else { - where[id] = `${whereFrom} IN (${value - .map((val) => sqlstring.escape(String(val).trim())) - .join(', ')})`; - } + where[id] = `${whereFrom} IN (${value + .map((val) => sqlstring.escape(String(val).trim())) + .join(', ')})`; } break; } @@ -633,15 +763,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map((val) => `x != ${sqlstring.escape(String(val).trim())}`) .join(' OR ')}, ${whereFrom})`; + } else if (value.length === 1) { + where[id] = + `${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`; } else { - if (value.length === 1) { - where[id] = - `${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`; - } else { - where[id] = `${whereFrom} NOT IN (${value - .map((val) => sqlstring.escape(String(val).trim())) - .join(', ')})`; - } + where[id] = `${whereFrom} NOT IN (${value + .map((val) => sqlstring.escape(String(val).trim())) + .join(', ')})`; } break; } @@ -649,15 +777,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { if (isWildcard) { where[id] = `arrayExists(x -> ${value .map( - (val) => - `x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, + (val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, + `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}` ) .join(' OR ')})`; } @@ -668,14 +795,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map( (val) => - `x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, + `x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, + `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}` ) .join(' OR ')})`; } @@ -685,14 +812,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { if (isWildcard) { where[id] = `arrayExists(x -> ${value .map( - (val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`, + (val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`, + `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}` ) .join(' OR ')})`; } @@ -702,14 +829,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { if (isWildcard) { where[id] = `arrayExists(x -> ${value .map( - (val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`, + (val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`, + `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}` ) .join(' OR ')})`; } @@ -724,7 +851,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`, + `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } @@ -752,14 +879,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map( (val) => - `toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } @@ -770,14 +897,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map( (val) => - `toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } @@ -788,14 +915,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map( (val) => - `toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } @@ -806,14 +933,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `arrayExists(x -> ${value .map( (val) => - `toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')}, ${whereFrom})`; } else { where[id] = `(${value .map( (val) => - `toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } @@ -856,7 +983,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, + `${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}` ) .join(' OR ')})`; break; @@ -865,7 +992,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, + `${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}` ) .join(' OR ')})`; break; @@ -874,7 +1001,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`, + `${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}` ) .join(' OR ')})`; break; @@ -883,7 +1010,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`, + `${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}` ) .join(' OR ')})`; break; @@ -892,7 +1019,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`, + `match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})` ) .join(' OR ')})`; break; @@ -902,7 +1029,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } else { @@ -917,7 +1044,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } else { @@ -932,13 +1059,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } else { where[id] = `(${value .map( - (val) => `${name} >= ${sqlstring.escape(String(val).trim())}`, + (val) => `${name} >= ${sqlstring.escape(String(val).trim())}` ) .join(' OR ')})`; } @@ -949,13 +1076,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { where[id] = `(${value .map( (val) => - `toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`, + `toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})` ) .join(' OR ')})`; } else { where[id] = `(${value .map( - (val) => `${name} <= ${sqlstring.escape(String(val).trim())}`, + (val) => `${name} <= ${sqlstring.escape(String(val).trim())}` ) .join(' OR ')})`; } @@ -974,15 +1101,15 @@ export function getChartStartEndDate( endDate, range, }: Pick, - timezone: string, + timezone: string ) { if (startDate && endDate) { - return { startDate: startDate, endDate: endDate }; + return { startDate, endDate }; } const ranges = getDatesFromRange(range, timezone); if (!startDate && endDate) { - return { startDate: ranges.startDate, endDate: endDate }; + return { startDate: ranges.startDate, endDate }; } return ranges; @@ -1002,8 +1129,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1018,8 +1145,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1035,8 +1162,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .endOf('day') .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1053,8 +1180,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1071,8 +1198,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1089,8 +1216,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1106,8 +1233,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1124,8 +1251,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1141,8 +1268,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1152,8 +1279,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1170,8 +1297,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) { .toFormat('yyyy-MM-dd HH:mm:ss'); return { - startDate: startDate, - endDate: endDate, + startDate, + endDate, }; } @@ -1183,7 +1310,7 @@ export function getChartPrevStartEndDate({ endDate: string; }) { let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff( - DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss'), + DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') ); // this will make sure our start and end date's are correct diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 7a7b4a25..ba369736 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -92,6 +92,7 @@ export interface IClickhouseEvent { sdk_name: string; sdk_version: string; revenue?: number; + groups: string[]; // They do not exist here. Just make ts happy for now profile?: IServiceProfile; @@ -143,6 +144,7 @@ export function transformSessionToEvent( importedAt: undefined, sdkName: undefined, sdkVersion: undefined, + groups: [], }; } @@ -179,6 +181,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent { sdkVersion: event.sdk_version, profile: event.profile, revenue: event.revenue, + groups: event.groups ?? [], }; } @@ -227,6 +230,7 @@ export interface IServiceEvent { sdkName: string | undefined; sdkVersion: string | undefined; revenue?: number; + groups: string[]; } type SelectHelper = { @@ -386,6 +390,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) { sdk_name: payload.sdkName ?? '', sdk_version: payload.sdkVersion ?? '', revenue: payload.revenue, + groups: payload.groups ?? [], }; const promises = [sessionBuffer.add(event), eventBuffer.add(event)]; diff --git a/packages/db/src/services/group.service.ts b/packages/db/src/services/group.service.ts new file mode 100644 index 00000000..687caf03 --- /dev/null +++ b/packages/db/src/services/group.service.ts @@ -0,0 +1,147 @@ +import { db } from '../prisma-client'; + +export type IServiceGroup = { + id: string; + projectId: string; + type: string; + name: string; + properties: Record; + createdAt: Date; + updatedAt: Date; +}; + +export type IServiceUpsertGroup = { + id: string; + projectId: string; + type: string; + name: string; + properties?: Record; +}; + +export async function upsertGroup(input: IServiceUpsertGroup) { + const { id, projectId, type, name, properties = {} } = input; + + await db.group.upsert({ + where: { + projectId_id: { projectId, id }, + }, + update: { + type, + name, + properties: properties as Record, + updatedAt: new Date(), + }, + create: { + id, + projectId, + type, + name, + properties: properties as Record, + }, + }); +} + +export async function getGroupById( + id: string, + projectId: string +): Promise { + const group = await db.group.findUnique({ + where: { projectId_id: { projectId, id } }, + }); + + if (!group) { + return null; + } + + return { + id: group.id, + projectId: group.projectId, + type: group.type, + name: group.name, + properties: (group.properties as Record) ?? {}, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + }; +} + +export async function getGroupList({ + projectId, + cursor, + take, + search, + type, +}: { + projectId: string; + cursor?: number; + take: number; + search?: string; + type?: string; +}): Promise { + const groups = await db.group.findMany({ + where: { + projectId, + ...(type ? { type } : {}), + ...(search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { id: { contains: search, mode: 'insensitive' } }, + ], + } + : {}), + }, + orderBy: { createdAt: 'desc' }, + take, + skip: cursor, + }); + + return groups.map((group) => ({ + id: group.id, + projectId: group.projectId, + type: group.type, + name: group.name, + properties: (group.properties as Record) ?? {}, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + })); +} + +export async function getGroupListCount({ + projectId, + type, + search, +}: { + projectId: string; + type?: string; + search?: string; +}): Promise { + return db.group.count({ + where: { + projectId, + ...(type ? { type } : {}), + ...(search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { id: { contains: search, mode: 'insensitive' } }, + ], + } + : {}), + }, + }); +} + +export async function getGroupTypes(projectId: string): Promise { + const groups = await db.group.findMany({ + where: { projectId }, + select: { type: true }, + distinct: ['type'], + }); + return groups.map((g) => g.type); +} + +export async function deleteGroup(id: string, projectId: string) { + return db.group.delete({ + where: { projectId_id: { projectId, id } }, + }); +} diff --git a/packages/db/src/services/import.service.ts b/packages/db/src/services/import.service.ts index ec146809..5d76c23d 100644 --- a/packages/db/src/services/import.service.ts +++ b/packages/db/src/services/import.service.ts @@ -355,6 +355,7 @@ export async function createSessionsStartEndEvents( profile_id: session.profile_id, project_id: session.project_id, session_id: session.session_id, + groups: [], path: firstPath, origin: firstOrigin, referrer: firstReferrer, @@ -390,6 +391,7 @@ export async function createSessionsStartEndEvents( profile_id: session.profile_id, project_id: session.project_id, session_id: session.session_id, + groups: [], path: lastPath, origin: lastOrigin, referrer: firstReferrer, diff --git a/packages/sdks/sdk/src/index.ts b/packages/sdks/sdk/src/index.ts index d6b834f9..34f52ec7 100644 --- a/packages/sdks/sdk/src/index.ts +++ b/packages/sdks/sdk/src/index.ts @@ -3,6 +3,7 @@ import type { IAliasPayload as AliasPayload, IDecrementPayload as DecrementPayload, + IGroupPayload as GroupPayload, IIdentifyPayload as IdentifyPayload, IIncrementPayload as IncrementPayload, ITrackHandlerPayload as TrackHandlerPayload, @@ -13,6 +14,7 @@ import { Api } from './api'; export type { AliasPayload, DecrementPayload, + GroupPayload, IdentifyPayload, IncrementPayload, TrackHandlerPayload, @@ -22,8 +24,11 @@ export type { export interface TrackProperties { [key: string]: unknown; profileId?: string; + groups?: string[]; } +export type GroupMetadata = Omit; + export interface OpenPanelOptions { clientId: string; clientSecret?: string; @@ -45,6 +50,7 @@ export class OpenPanel { api: Api; options: OpenPanelOptions; profileId?: string; + groups: string[] = []; deviceId?: string; sessionId?: string; global?: Record; @@ -142,14 +148,19 @@ export class OpenPanel { track(name: string, properties?: TrackProperties) { this.log('track event', name, properties); + const { groups: groupsOverride, profileId, ...rest } = properties ?? {}; + const mergedGroups = [ + ...new Set([...this.groups, ...(groupsOverride ?? [])]), + ]; return this.send({ type: 'track', payload: { name, - profileId: properties?.profileId ?? this.profileId, + profileId: profileId ?? this.profileId, + groups: mergedGroups.length > 0 ? mergedGroups : undefined, properties: { ...(this.global ?? {}), - ...(properties ?? {}), + ...rest, }, }, }); @@ -176,6 +187,27 @@ export class OpenPanel { } } + setGroups(groupIds: string[]) { + this.log('set groups', groupIds); + this.groups = groupIds; + } + + setGroup(groupId: string, metadata?: GroupMetadata) { + this.log('set group', groupId, metadata); + if (!this.groups.includes(groupId)) { + this.groups = [...this.groups, groupId]; + } + if (metadata) { + return this.send({ + type: 'group', + payload: { + id: groupId, + ...metadata, + }, + }); + } + } + /** * @deprecated This method is deprecated and will be removed in a future version. */ @@ -227,10 +259,47 @@ export class OpenPanel { clear() { this.profileId = undefined; + this.groups = []; this.deviceId = undefined; this.sessionId = undefined; } + private buildFlushPayload( + item: TrackHandlerPayload + ): TrackHandlerPayload['payload'] { + if (item.type === 'replay') { + return item.payload; + } + if (item.type === 'track') { + const queuedGroups = + 'groups' in item.payload ? (item.payload.groups ?? []) : []; + const mergedGroups = [...new Set([...this.groups, ...queuedGroups])]; + return { + ...item.payload, + profileId: + 'profileId' in item.payload + ? (item.payload.profileId ?? this.profileId) + : this.profileId, + groups: mergedGroups.length > 0 ? mergedGroups : undefined, + }; + } + if ( + item.type === 'identify' || + item.type === 'increment' || + item.type === 'decrement' + ) { + return { + ...item.payload, + profileId: String( + 'profileId' in item.payload + ? (item.payload.profileId ?? this.profileId) + : (this.profileId ?? '') + ), + } as TrackHandlerPayload['payload']; + } + return item.payload; + } + flush() { const remaining: TrackHandlerPayload[] = []; for (const item of this.queue) { @@ -238,16 +307,7 @@ export class OpenPanel { remaining.push(item); continue; } - const payload = - item.type === 'replay' - ? item.payload - : { - ...item.payload, - profileId: - 'profileId' in item.payload - ? (item.payload.profileId ?? this.profileId) - : this.profileId, - }; + const payload = this.buildFlushPayload(item); this.send({ ...item, payload } as TrackHandlerPayload); } this.queue = remaining; diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 626c1312..808de8c6 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -1,11 +1,12 @@ import { authRouter } from './routers/auth'; -import { gscRouter } from './routers/gsc'; import { chartRouter } from './routers/chart'; import { chatRouter } from './routers/chat'; import { clientRouter } from './routers/client'; import { dashboardRouter } from './routers/dashboard'; import { emailRouter } from './routers/email'; import { eventRouter } from './routers/event'; +import { groupRouter } from './routers/group'; +import { gscRouter } from './routers/gsc'; import { importRouter } from './routers/import'; import { insightRouter } from './routers/insight'; import { integrationRouter } from './routers/integration'; @@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({ widget: widgetRouter, email: emailRouter, gsc: gscRouter, + group: groupRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/group.ts b/packages/trpc/src/routers/group.ts new file mode 100644 index 00000000..31fcc01e --- /dev/null +++ b/packages/trpc/src/routers/group.ts @@ -0,0 +1,125 @@ +import { + chQuery, + db, + deleteGroup, + getGroupById, + getGroupList, + getGroupListCount, + getGroupTypes, + TABLE_NAMES, +} from '@openpanel/db'; +import sqlstring from 'sqlstring'; +import { z } from 'zod'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +export const groupRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z.object({ + projectId: z.string(), + cursor: z.number().optional(), + take: z.number().default(50), + search: z.string().optional(), + type: z.string().optional(), + }) + ) + .query(async ({ input }) => { + const [data, count] = await Promise.all([ + getGroupList(input), + getGroupListCount(input), + ]); + return { data, meta: { count, take: input.take } }; + }), + + byId: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return getGroupById(id, projectId); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .mutation(async ({ input: { id, projectId } }) => { + return deleteGroup(id, projectId); + }), + + types: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input: { projectId } }) => { + return getGroupTypes(projectId); + }), + + metrics: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return chQuery<{ + totalEvents: number; + uniqueProfiles: number; + firstSeen: string; + lastSeen: string; + }>(` + SELECT + count() AS totalEvents, + uniqExact(profile_id) AS uniqueProfiles, + min(created_at) AS firstSeen, + max(created_at) AS lastSeen + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + `); + }), + + activity: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return chQuery<{ count: number; date: string }>(` + SELECT count() AS count, toStartOfDay(created_at) AS date + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + GROUP BY date + ORDER BY date DESC + `); + }), + + members: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return chQuery<{ + profileId: string; + lastSeen: string; + eventCount: number; + }>(` + SELECT + profile_id AS profileId, + max(created_at) AS lastSeen, + count() AS eventCount + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + AND profile_id != device_id + GROUP BY profile_id + ORDER BY lastSeen DESC + LIMIT 100 + `); + }), + + properties: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input: { projectId } }) => { + // Returns distinct property keys across all groups for this project + // Used by breakdown/filter pickers in the chart builder + const groups = await db.group.findMany({ + where: { projectId }, + select: { properties: true }, + }); + const keys = new Set(); + for (const group of groups) { + const props = group.properties as Record; + for (const key of Object.keys(props)) { + keys.add(key); + } + } + return Array.from(keys).sort(); + }), +}); diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts index 015c5e90..93b65329 100644 --- a/packages/validation/src/track.validation.ts +++ b/packages/validation/src/track.validation.ts @@ -2,11 +2,19 @@ import { RESERVED_EVENT_NAMES } from '@openpanel/constants'; import { z } from 'zod'; import { isBlockedEventName } from './event-blocklist'; +export const zGroupPayload = z.object({ + id: z.string().min(1), + type: z.string().min(1), + name: z.string().min(1), + properties: z.record(z.unknown()).optional(), +}); + export const zTrackPayload = z .object({ name: z.string().min(1), properties: z.record(z.string(), z.unknown()).optional(), profileId: z.string().or(z.number()).optional(), + groups: z.array(z.string()).optional(), }) .refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), { message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`, @@ -97,6 +105,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [ type: z.literal('replay'), payload: zReplayPayload, }), + z.object({ + type: z.literal('group'), + payload: zGroupPayload, + }), ]); export type ITrackPayload = z.infer; @@ -105,6 +117,7 @@ export type IIncrementPayload = z.infer; export type IDecrementPayload = z.infer; export type IAliasPayload = z.infer; export type IReplayPayload = z.infer; +export type IGroupPayload = z.infer; export type ITrackHandlerPayload = z.infer; // Deprecated types for beta version of the SDKs