This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-18 18:28:23 +01:00
parent d1b39c4c93
commit 765e4aa107
24 changed files with 1332 additions and 200 deletions

View File

@@ -4,6 +4,7 @@ import {
getProfileById, getProfileById,
getSalts, getSalts,
replayBuffer, replayBuffer,
upsertGroup,
upsertProfile, upsertProfile,
} from '@openpanel/db'; } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
@@ -14,6 +15,7 @@ import {
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { import {
type IDecrementPayload, type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload, type IIdentifyPayload,
type IIncrementPayload, type IIncrementPayload,
type IReplayPayload, type IReplayPayload,
@@ -218,6 +220,7 @@ async function handleTrack(
headers, headers,
event: { event: {
...payload, ...payload,
groups: payload.groups ?? [],
timestamp: timestamp.value, timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast, isTimestampFromThePast: timestamp.isFromPast,
}, },
@@ -333,6 +336,20 @@ async function handleReplay(
await replayBuffer.add(row); await replayBuffer.add(row);
} }
async function handleGroup(
payload: IGroupPayload,
context: TrackContext
): Promise<void> {
const { id, type, name, properties = {} } = payload;
await upsertGroup({
id,
projectId: context.projectId,
type,
name,
properties,
});
}
export async function handler( export async function handler(
request: FastifyRequest<{ request: FastifyRequest<{
Body: ITrackHandlerPayload; Body: ITrackHandlerPayload;
@@ -381,6 +398,9 @@ export async function handler(
case 'replay': case 'replay':
await handleReplay(validatedBody.payload, context); await handleReplay(validatedBody.payload, context);
break; break;
case 'group':
await handleGroup(validatedBody.payload, context);
break;
default: default:
return reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,

View File

@@ -1,5 +1,8 @@
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import { import {
ActivityIcon, ActivityIcon,
Building2Icon,
ClockIcon, ClockIcon,
EqualApproximatelyIcon, EqualApproximatelyIcon,
type LucideIcon, type LucideIcon,
@@ -10,10 +13,7 @@ import {
UserCheckIcon, UserCheckIcon,
UsersIcon, UsersIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '../ui/button';
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -25,7 +25,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Button } from '../ui/button';
interface ReportChartTypeProps { interface ReportChartTypeProps {
className?: string; className?: string;
@@ -46,6 +45,7 @@ export function ReportSegment({
event: ActivityIcon, event: ActivityIcon,
user: UsersIcon, user: UsersIcon,
session: ClockIcon, session: ClockIcon,
group: Building2Icon,
user_average: UserCheck2Icon, user_average: UserCheck2Icon,
one_event_per_user: UserCheckIcon, one_event_per_user: UserCheckIcon,
property_sum: SigmaIcon, property_sum: SigmaIcon,
@@ -58,9 +58,9 @@ export function ReportSegment({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline"
icon={Icons[value]}
className={cn('justify-start text-sm', className)} className={cn('justify-start text-sm', className)}
icon={Icons[value]}
variant="outline"
> >
{items.find((item) => item.value === value)?.label} {items.find((item) => item.value === value)?.label}
</Button> </Button>
@@ -74,13 +74,13 @@ export function ReportSegment({
const Icon = Icons[item.value]; const Icon = Icons[item.value];
return ( return (
<DropdownMenuItem <DropdownMenuItem
className="group"
key={item.value} key={item.value}
onClick={() => onChange(item.value)} onClick={() => onChange(item.value)}
className="group"
> >
{item.label} {item.label}
<DropdownMenuShortcut> <DropdownMenuShortcut>
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" /> <Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
); );

View File

@@ -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 { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -10,11 +21,7 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties'; import { useEventProperties } from '@/hooks/use-event-properties';
import type { IChartEvent } from '@openpanel/validation'; import { useTRPC } from '@/integrations/trpc/react';
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';
interface PropertiesComboboxProps { interface PropertiesComboboxProps {
event?: IChartEvent; event?: IChartEvent;
@@ -40,15 +47,15 @@ function SearchHeader({
return ( return (
<div className="row items-center gap-1"> <div className="row items-center gap-1">
{!!onBack && ( {!!onBack && (
<Button variant="ghost" size="icon" onClick={onBack}> <Button onClick={onBack} size="icon" variant="ghost">
<ArrowLeftIcon className="size-4" /> <ArrowLeftIcon className="size-4" />
</Button> </Button>
)} )}
<Input <Input
autoFocus
onChange={(e) => onSearch(e.target.value)}
placeholder="Search" placeholder="Search"
value={value} value={value}
onChange={(e) => onSearch(e.target.value)}
autoFocus
/> />
</div> </div>
); );
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
exclude = [], exclude = [],
}: PropertiesComboboxProps) { }: PropertiesComboboxProps) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const trpc = useTRPC();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const properties = useEventProperties({ const properties = useEventProperties({
event: event?.name, event: event?.name,
projectId, 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 [search, setSearch] = useState('');
const [direction, setDirection] = useState<'forward' | 'backward'>('forward'); const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile'); setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
} }
}, [open, mode]); }, [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 const profileActions = properties
.filter( .filter(
(property) => (property) =>
property.startsWith('profile') && shouldShowProperty(property), property.startsWith('profile') && shouldShowProperty(property)
) )
.map((property) => ({ .map((property) => ({
value: property, value: property,
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
const eventActions = properties const eventActions = properties
.filter( .filter(
(property) => (property) =>
!property.startsWith('profile') && shouldShowProperty(property), !property.startsWith('profile') && shouldShowProperty(property)
) )
.map((property) => ({ .map((property) => ({
value: property, value: property,
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
description: property.split('.').slice(0, -1).join('.'), 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'); setDirection(newState === 'index' ? 'backward' : 'forward');
setState(newState); setState(newState);
}; };
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
}} }}
> >
Event properties Event properties
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" /> <DatabaseIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="group justify-between gap-2" className="group justify-between gap-2"
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
}} }}
> >
Profile properties Profile properties
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" /> <UserIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem>
<DropdownMenuItem
className="group justify-between gap-2"
onClick={(e) => {
e.preventDefault();
handleStateChange('group');
}}
>
Group properties
<Building2Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
); );
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
const filteredActions = eventActions.filter( const filteredActions = eventActions.filter(
(action) => (action) =>
action.label.toLowerCase().includes(search.toLowerCase()) || action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()), action.description.toLowerCase().includes(search.toLowerCase())
); );
return ( return (
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<VirtualList <VirtualList
height={300}
data={filteredActions} data={filteredActions}
height={300}
itemHeight={40} itemHeight={40}
itemKey="id" itemKey="id"
> >
{(action) => ( {(action) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px" className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)} onClick={() => handleSelect(action)}
> >
<div className="font-medium">{action.label}</div> <div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{action.description} {action.description}
</div> </div>
</motion.div> </motion.div>
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
const filteredActions = profileActions.filter( const filteredActions = profileActions.filter(
(action) => (action) =>
action.label.toLowerCase().includes(search.toLowerCase()) || action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase()), action.description.toLowerCase().includes(search.toLowerCase())
); );
return ( return (
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<VirtualList <VirtualList
height={300}
data={filteredActions} data={filteredActions}
height={300}
itemHeight={40} itemHeight={40}
itemKey="id" itemKey="id"
> >
{(action) => ( {(action) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px" className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)} onClick={() => handleSelect(action)}
> >
<div className="font-medium">{action.label}</div> <div className="font-medium">{action.label}</div>
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{action.description}
</div>
</motion.div>
)}
</VirtualList>
</div>
);
};
const renderGroup = () => {
const filteredActions = groupActions.filter(
(action) =>
action.label.toLowerCase().includes(search.toLowerCase()) ||
action.description.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col">
<SearchHeader
onBack={() => handleStateChange('index')}
onSearch={setSearch}
value={search}
/>
<DropdownMenuSeparator />
<VirtualList
data={filteredActions}
height={Math.min(300, filteredActions.length * 40 + 8)}
itemHeight={40}
itemKey="value"
>
{(action) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
initial={{ opacity: 0, y: 10 }}
onClick={() => handleSelect(action)}
>
<div className="font-medium">{action.label}</div>
<div className="text-muted-foreground text-sm">
{action.description} {action.description}
</div> </div>
</motion.div> </motion.div>
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
return ( return (
<DropdownMenu <DropdownMenu
open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
setOpen(open); setOpen(open);
}} }}
open={open}
> >
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-80" align="start"> <DropdownMenuContent align="start" className="max-w-80">
<AnimatePresence mode="wait" initial={false}> <AnimatePresence initial={false} mode="wait">
{state === 'index' && ( {state === 'index' && (
<motion.div <motion.div
key="index"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="index"
transition={{ duration: 0.05 }} transition={{ duration: 0.05 }}
> >
{renderIndex()} {renderIndex()}
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
)} )}
{state === 'event' && ( {state === 'event' && (
<motion.div <motion.div
key="event"
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }} exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="event"
transition={{ duration: 0.05 }} transition={{ duration: 0.05 }}
> >
{renderEvent()} {renderEvent()}
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
)} )}
{state === 'profile' && ( {state === 'profile' && (
<motion.div <motion.div
key="profile"
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }} exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="profile"
transition={{ duration: 0.05 }} transition={{ duration: 0.05 }}
> >
{renderProfile()} {renderProfile()}
</motion.div> </motion.div>
)} )}
{state === 'group' && (
<motion.div
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key="group"
transition={{ duration: 0.05 }}
>
{renderGroup()}
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { import {
BellIcon, BellIcon,
BookOpenIcon, BookOpenIcon,
Building2Icon,
ChartLineIcon, ChartLineIcon,
ChevronDownIcon, ChevronDownIcon,
CogIcon, CogIcon,
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" /> <SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" /> <SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" /> <SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm"> <div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
Manage Manage
</div> </div>

View File

@@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime' import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages' import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights' 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 AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat' import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index' 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 AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId'
import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs' 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 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 AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs'
import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId' import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId'
import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index' import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index'
@@ -350,6 +352,12 @@ const AppOrganizationIdProjectIdInsightsRoute =
path: '/insights', path: '/insights',
getParentRoute: () => AppOrganizationIdProjectIdRoute, getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsRoute =
AppOrganizationIdProjectIdGroupsRouteImport.update({
id: '/groups',
path: '/groups',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdDashboardsRoute = const AppOrganizationIdProjectIdDashboardsRoute =
AppOrganizationIdProjectIdDashboardsRouteImport.update({ AppOrganizationIdProjectIdDashboardsRouteImport.update({
id: '/dashboards', id: '/dashboards',
@@ -443,6 +451,12 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute =
id: '/_tabs', id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute, getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute,
} as any) } as any)
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
id: '/groups_/$groupId',
path: '/groups/$groupId',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdEventsTabsRoute = const AppOrganizationIdProjectIdEventsTabsRoute =
AppOrganizationIdProjectIdEventsTabsRouteImport.update({ AppOrganizationIdProjectIdEventsTabsRouteImport.update({
id: '/_tabs', id: '/_tabs',
@@ -615,6 +629,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -630,6 +645,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute '/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren '/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren '/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren '/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -688,6 +704,7 @@ export interface FileRoutesByTo {
'/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -703,6 +720,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute '/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute '/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -760,6 +778,7 @@ export interface FileRoutesById {
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute '/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -779,6 +798,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren '/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren
'/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren '/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
'/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren '/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren '/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren '/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
@@ -845,6 +865,7 @@ export interface FileRouteTypes {
| '/$organizationId/' | '/$organizationId/'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/groups'
| '/$organizationId/$projectId/insights' | '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
@@ -860,6 +881,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/' | '/$organizationId/$projectId/'
| '/$organizationId/$projectId/dashboards/$dashboardId' | '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events' | '/$organizationId/$projectId/events'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications' | '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles' | '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId' | '/$organizationId/$projectId/reports/$reportId'
@@ -918,6 +940,7 @@ export interface FileRouteTypes {
| '/$organizationId' | '/$organizationId'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/groups'
| '/$organizationId/$projectId/insights' | '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
@@ -933,6 +956,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId' | '/$organizationId/$projectId'
| '/$organizationId/$projectId/dashboards/$dashboardId' | '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events' | '/$organizationId/$projectId/events'
| '/$organizationId/$projectId/groups/$groupId'
| '/$organizationId/$projectId/notifications' | '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles' | '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId' | '/$organizationId/$projectId/reports/$reportId'
@@ -989,6 +1013,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/' | '/_app/$organizationId/'
| '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/chat'
| '/_app/$organizationId/$projectId/dashboards' | '/_app/$organizationId/$projectId/dashboards'
| '/_app/$organizationId/$projectId/groups'
| '/_app/$organizationId/$projectId/insights' | '/_app/$organizationId/$projectId/insights'
| '/_app/$organizationId/$projectId/pages' | '/_app/$organizationId/$projectId/pages'
| '/_app/$organizationId/$projectId/realtime' | '/_app/$organizationId/$projectId/realtime'
@@ -1008,6 +1033,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/dashboards_/$dashboardId' | '/_app/$organizationId/$projectId/dashboards_/$dashboardId'
| '/_app/$organizationId/$projectId/events' | '/_app/$organizationId/$projectId/events'
| '/_app/$organizationId/$projectId/events/_tabs' | '/_app/$organizationId/$projectId/events/_tabs'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/notifications' | '/_app/$organizationId/$projectId/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs' | '/_app/$organizationId/$projectId/notifications/_tabs'
| '/_app/$organizationId/$projectId/profiles' | '/_app/$organizationId/$projectId/profiles'
@@ -1378,6 +1404,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute 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': { '/_app/$organizationId/$projectId/dashboards': {
id: '/_app/$organizationId/$projectId/dashboards' id: '/_app/$organizationId/$projectId/dashboards'
path: '/dashboards' path: '/dashboards'
@@ -1490,6 +1523,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute 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': { '/_app/$organizationId/$projectId/events/_tabs': {
id: '/_app/$organizationId/$projectId/events/_tabs' id: '/_app/$organizationId/$projectId/events/_tabs'
path: '/events' path: '/events'
@@ -1875,6 +1915,7 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
interface AppOrganizationIdProjectIdRouteChildren { interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
@@ -1885,6 +1926,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -1897,6 +1939,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute, AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
AppOrganizationIdProjectIdDashboardsRoute: AppOrganizationIdProjectIdDashboardsRoute:
AppOrganizationIdProjectIdDashboardsRoute, AppOrganizationIdProjectIdDashboardsRoute,
AppOrganizationIdProjectIdGroupsRoute:
AppOrganizationIdProjectIdGroupsRoute,
AppOrganizationIdProjectIdInsightsRoute: AppOrganizationIdProjectIdInsightsRoute:
AppOrganizationIdProjectIdInsightsRoute, AppOrganizationIdProjectIdInsightsRoute,
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute, AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
@@ -1914,6 +1958,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdDashboardsDashboardIdRoute, AppOrganizationIdProjectIdDashboardsDashboardIdRoute,
AppOrganizationIdProjectIdEventsRoute: AppOrganizationIdProjectIdEventsRoute:
AppOrganizationIdProjectIdEventsRouteWithChildren, AppOrganizationIdProjectIdEventsRouteWithChildren,
AppOrganizationIdProjectIdGroupsGroupIdRoute:
AppOrganizationIdProjectIdGroupsGroupIdRoute,
AppOrganizationIdProjectIdNotificationsRoute: AppOrganizationIdProjectIdNotificationsRoute:
AppOrganizationIdProjectIdNotificationsRouteWithChildren, AppOrganizationIdProjectIdNotificationsRouteWithChildren,
AppOrganizationIdProjectIdProfilesRoute: AppOrganizationIdProjectIdProfilesRoute:

View File

@@ -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 (
<PageContainer>
<PageHeader
description="Groups represent companies, teams, or other entities that events belong to."
title="Groups"
/>
<div className="mb-6 flex flex-wrap gap-3">
<Input
className="w-64"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
value={search}
/>
{types.length > 0 && (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">No groups found</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-def-100">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Name
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
ID
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Type
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Created
</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr
className="border-b transition-colors last:border-0 hover:bg-def-100"
key={`${group.projectId}-${group.id}`}
>
<td className="px-4 py-3">
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-muted-foreground text-xs">
{group.id}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{group.type}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDateTime(new Date(group.createdAt))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}

View File

@@ -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 (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
const properties = g.properties as Record<string, unknown>;
return (
<PageContainer>
<PageHeader
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
<Badge className="shrink-0" variant="outline">
{g.type}
</Badge>
</div>
}
>
<p className="font-mono text-muted-foreground text-sm">{g.id}</p>
</PageHeader>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody>
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left font-medium text-muted-foreground">
Profile
</th>
<th className="py-2 text-right font-medium text-muted-foreground">
Events
</th>
</tr>
</thead>
<tbody>
{members.data.map((member) => (
<tr
className="border-b last:border-0"
key={member.profileId}
>
<td className="py-2">
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
</td>
<td className="py-2 text-right text-muted-foreground">
{member.eventCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</WidgetBody>
</Widget>
</div>
</div>
</PageContainer>
);
}

View File

@@ -10,7 +10,7 @@ const BASE_TITLE = 'OpenPanel.dev';
export function createTitle( export function createTitle(
pageTitle: string, pageTitle: string,
section?: string, section?: string,
baseTitle = BASE_TITLE, baseTitle = BASE_TITLE
): string { ): string {
const parts = [pageTitle]; const parts = [pageTitle];
if (section) { if (section) {
@@ -25,7 +25,7 @@ export function createTitle(
*/ */
export function createOrganizationTitle( export function createOrganizationTitle(
pageTitle: string, pageTitle: string,
organizationName?: string, organizationName?: string
): string { ): string {
if (organizationName) { if (organizationName) {
return createTitle(pageTitle, organizationName); return createTitle(pageTitle, organizationName);
@@ -39,7 +39,7 @@ export function createOrganizationTitle(
export function createProjectTitle( export function createProjectTitle(
pageTitle: string, pageTitle: string,
projectName?: string, projectName?: string,
organizationName?: string, organizationName?: string
): string { ): string {
const parts = [pageTitle]; const parts = [pageTitle];
if (projectName) { if (projectName) {
@@ -59,7 +59,7 @@ export function createEntityTitle(
entityName: string, entityName: string,
entityType: string, entityType: string,
projectName?: string, projectName?: string,
organizationName?: string, organizationName?: string
): string { ): string {
const parts = [entityName, entityType]; const parts = [entityName, entityType];
if (projectName) { if (projectName) {
@@ -95,6 +95,9 @@ export const PAGE_TITLES = {
PROFILES: 'Profiles', PROFILES: 'Profiles',
PROFILE_EVENTS: 'Profile events', PROFILE_EVENTS: 'Profile events',
PROFILE_DETAILS: 'Profile details', PROFILE_DETAILS: 'Profile details',
// Groups
GROUPS: 'Groups',
GROUP_DETAILS: 'Group details',
// Sub-sections // Sub-sections
CONVERSIONS: 'Conversions', CONVERSIONS: 'Conversions',

View File

@@ -134,6 +134,7 @@ export async function incomingEvent(
__hash: hash, __hash: hash,
__query: query, __query: query,
}), }),
groups: body.groups ?? [],
createdAt, createdAt,
duration: 0, duration: 0,
sdkName, sdkName,

View File

@@ -63,7 +63,8 @@
}, },
"performance": { "performance": {
"noDelete": "off", "noDelete": "off",
"noAccumulatingSpread": "off" "noAccumulatingSpread": "off",
"noBarrelFile": "off"
}, },
"suspicious": { "suspicious": {
"noExplicitAny": "off", "noExplicitAny": "off",

View File

@@ -113,6 +113,7 @@ export const chartSegments = {
event: 'All events', event: 'All events',
user: 'Unique users', user: 'Unique users',
session: 'Unique sessions', session: 'Unique sessions',
group: 'Unique groups',
user_average: 'Average users', user_average: 'Average users',
one_event_per_user: 'One event per user', one_event_per_user: 'One event per user',
property_sum: 'Sum of property', property_sum: 'Sum of property',
@@ -195,7 +196,7 @@ export const metrics = {
} as const; } as const;
export function isMinuteIntervalEnabledByRange( export function isMinuteIntervalEnabledByRange(
range: keyof typeof timeWindows, range: keyof typeof timeWindows
) { ) {
return range === '30min' || range === 'lastHour'; return range === '30min' || range === 'lastHour';
} }
@@ -210,7 +211,7 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeWindows) {
} }
export function getDefaultIntervalByRange( export function getDefaultIntervalByRange(
range: keyof typeof timeWindows, range: keyof typeof timeWindows
): keyof typeof intervals { ): keyof typeof intervals {
if (range === '30min' || range === 'lastHour') { if (range === '30min' || range === 'lastHour') {
return 'minute'; return 'minute';
@@ -231,7 +232,7 @@ export function getDefaultIntervalByRange(
export function getDefaultIntervalByDates( export function getDefaultIntervalByDates(
startDate: string | null, startDate: string | null,
endDate: string | null, endDate: string | null
): null | keyof typeof intervals { ): null | keyof typeof intervals {
if (startDate && endDate) { if (startDate && endDate) {
if (isSameDay(startDate, endDate)) { if (isSameDay(startDate, endDate)) {

View File

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

View File

@@ -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/buffers';
export * from './src/types'; export * from './src/clickhouse/client';
export * from './src/clickhouse/query-builder'; 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/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/overview.service';
export * from './src/services/pages.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/session-context';
export * from './src/gsc'; export * from './src/sql-builder';
export * from './src/encryption'; export * from './src/types';

View File

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

View File

@@ -199,6 +199,7 @@ model Project {
meta EventMeta[] meta EventMeta[]
references Reference[] references Reference[]
access ProjectAccess[] access ProjectAccess[]
groups Group[]
notificationRules NotificationRule[] notificationRules NotificationRule[]
notifications Notification[] notifications Notification[]
@@ -215,6 +216,20 @@ model Project {
@@map("projects") @@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 { enum AccessLevel {
read read
write write

View File

@@ -61,6 +61,7 @@ export const TABLE_NAMES = {
gsc_daily: 'gsc_daily', gsc_daily: 'gsc_daily',
gsc_pages_daily: 'gsc_pages_daily', gsc_pages_daily: 'gsc_pages_daily',
gsc_queries_daily: 'gsc_queries_daily', gsc_queries_daily: 'gsc_queries_daily',
groups: 'groups',
}; };
/** /**

View File

@@ -1,20 +1,18 @@
import sqlstring from 'sqlstring';
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common'; import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
import type { import type {
IChartEventFilter, IChartEventFilter,
IReportInput,
IChartRange, IChartRange,
IGetChartDataInput, IGetChartDataInput,
IReportInput,
} from '@openpanel/validation'; } from '@openpanel/validation';
import sqlstring from 'sqlstring';
import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client'; import { formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client';
import { createSqlBuilder } from '../sql-builder'; import { createSqlBuilder } from '../sql-builder';
export function transformPropertyKey(property: string) { export function transformPropertyKey(property: string) {
const propertyPatterns = ['properties', 'profile.properties']; const propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) => const match = propertyPatterns.find((pattern) =>
property.startsWith(`${pattern}.`), property.startsWith(`${pattern}.`)
); );
if (!match) { if (!match) {
@@ -32,21 +30,49 @@ export function transformPropertyKey(property: string) {
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`; 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') { if (property === 'has_profile') {
return `if(profile_id != device_id, 'true', 'false')`; 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 propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) => const match = propertyPatterns.find((pattern) =>
property.startsWith(`${pattern}.`), property.startsWith(`${pattern}.`)
); );
if (!match) return property; if (!match) {
return property;
}
if (property.includes('*')) { if (property.includes('*')) {
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape( return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape(
transformPropertyKey(property), transformPropertyKey(property)
)})))`; )})))`;
} }
@@ -78,7 +104,7 @@ export function getChartSql({
with: addCte, with: addCte,
} = createSqlBuilder(); } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters); sb.where = getEventFiltersWhereClause(event.filters, projectId);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`; sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (event.name !== '*') { if (event.name !== '*') {
@@ -89,11 +115,23 @@ export function getChartSql({
} }
const anyFilterOnProfile = event.filters.some((filter) => const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'), filter.name.startsWith('profile.')
); );
const anyBreakdownOnProfile = breakdowns.some((breakdown) => 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) // Build WHERE clause without the bar filter (for use in subqueries and CTEs)
// Define this early so we can use it in CTE definitions // Define this early so we can use it in CTE definitions
@@ -178,8 +216,8 @@ export function getChartSql({
addCte( addCte(
'profile', 'profile',
`SELECT ${selectFields.join(', ')} `SELECT ${selectFields.join(', ')}
FROM ${TABLE_NAMES.profiles} FINAL FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`, WHERE project_id = ${sqlstring.escape(projectId)}`
); );
// Use the CTE reference in the main query // 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 // Use CTE to define top breakdown values once, then reference in WHERE clause
if (breakdowns.length > 0 && limit) { if (breakdowns.length > 0 && limit) {
const breakdownSelects = breakdowns const breakdownSelects = breakdowns
.map((b) => getSelectPropertyKey(b.name)) .map((b) => getSelectPropertyKey(b.name, projectId))
.join(', '); .join(', ');
const groupArrayJoinClause = needsGroupArrayJoin
? 'ARRAY JOIN groups AS _group_id'
: '';
// Add top_breakdowns CTE using the builder // Add top_breakdowns CTE using the builder
addCte( addCte(
'top_breakdowns', 'top_breakdowns',
`SELECT ${breakdownSelects} `SELECT ${breakdownSelects}
FROM ${TABLE_NAMES.events} e FROM ${TABLE_NAMES.events} e
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()} ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()}
GROUP BY ${breakdownSelects} GROUP BY ${breakdownSelects}
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT ${limit}`, LIMIT ${limit}`
); );
// Filter main query to only include top breakdown values // 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.forEach((breakdown, index) => {
// Breakdowns start at label_1 (label_0 is reserved for event name) // Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`; 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}`; sb.groupBy[key] = `${key}`;
}); });
@@ -261,6 +304,10 @@ export function getChartSql({
sb.select.count = 'countDistinct(session_id) as count'; 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') { if (event.segment === 'user_average') {
sb.select.count = sb.select.count =
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count'; 'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
@@ -289,7 +336,7 @@ export function getChartSql({
sb.from = `( sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
sb.where, sb.where,
' AND ', ' AND '
)} )}
ORDER BY profile_id, created_at DESC ORDER BY profile_id, created_at DESC
) as subQuery`; ) as subQuery`;
@@ -308,7 +355,7 @@ export function getChartSql({
// Since outer query groups by label_X, we reference those in the correlation // Since outer query groups by label_X, we reference those in the correlation
const breakdownMatches = breakdowns const breakdownMatches = breakdowns
.map((b, index) => { .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 // Correlate: match the property expression with outer query's label_X value
// ClickHouse allows referencing outer query columns in correlated subqueries // ClickHouse allows referencing outer query columns in correlated subqueries
return `${propertyKey} = label_${index + 1}`; return `${propertyKey} = label_${index + 1}`;
@@ -359,7 +406,7 @@ export function getAggregateChartSql({
}) { }) {
const { sb, join, getJoins, with: addCte, getSql } = createSqlBuilder(); 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)}`; sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (event.name !== '*') { if (event.name !== '*') {
@@ -370,11 +417,23 @@ export function getAggregateChartSql({
} }
const anyFilterOnProfile = event.filters.some((filter) => const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'), filter.name.startsWith('profile.')
); );
const anyBreakdownOnProfile = breakdowns.some((breakdown) => 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) // Build WHERE clause without the bar filter (for use in subqueries and CTEs)
const getWhereWithoutBar = () => { const getWhereWithoutBar = () => {
@@ -455,8 +514,8 @@ export function getAggregateChartSql({
addCte( addCte(
'profile', 'profile',
`SELECT ${selectFields.join(', ')} `SELECT ${selectFields.join(', ')}
FROM ${TABLE_NAMES.profiles} FINAL FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`, WHERE project_id = ${sqlstring.escape(projectId)}`
); );
sb.joins.profiles = profilesJoinRef; sb.joins.profiles = profilesJoinRef;
@@ -478,28 +537,33 @@ export function getAggregateChartSql({
// Use CTE to define top breakdown values once, then reference in WHERE clause // Use CTE to define top breakdown values once, then reference in WHERE clause
if (breakdowns.length > 0 && limit) { if (breakdowns.length > 0 && limit) {
const breakdownSelects = breakdowns const breakdownSelects = breakdowns
.map((b) => getSelectPropertyKey(b.name)) .map((b) => getSelectPropertyKey(b.name, projectId))
.join(', '); .join(', ');
const groupArrayJoinClause = needsGroupArrayJoin
? 'ARRAY JOIN groups AS _group_id'
: '';
addCte( addCte(
'top_breakdowns', 'top_breakdowns',
`SELECT ${breakdownSelects} `SELECT ${breakdownSelects}
FROM ${TABLE_NAMES.events} e FROM ${TABLE_NAMES.events} e
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()} ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()}
GROUP BY ${breakdownSelects} GROUP BY ${breakdownSelects}
ORDER BY count(*) DESC ORDER BY count(*) DESC
LIMIT ${limit}`, LIMIT ${limit}`
); );
// Filter main query to only include top breakdown values // 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 // Add breakdowns to SELECT and GROUP BY
breakdowns.forEach((breakdown, index) => { breakdowns.forEach((breakdown, index) => {
// Breakdowns start at label_1 (label_0 is reserved for event name) // Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`; 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}`; sb.groupBy[key] = `${key}`;
}); });
@@ -518,6 +582,10 @@ export function getAggregateChartSql({
sb.select.count = 'countDistinct(session_id) as count'; 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') { if (event.segment === 'user_average') {
sb.select.count = sb.select.count =
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count'; 'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
@@ -531,7 +599,7 @@ export function getAggregateChartSql({
}[event.segment as string]; }[event.segment as string];
if (mathFunction && event.property) { if (mathFunction && event.property) {
const propertyKey = getSelectPropertyKey(event.property); const propertyKey = getSelectPropertyKey(event.property, projectId);
if (isNumericColumn(event.property)) { if (isNumericColumn(event.property)) {
sb.select.count = `${mathFunction}(${propertyKey}) as count`; sb.select.count = `${mathFunction}(${propertyKey}) as count`;
@@ -546,7 +614,7 @@ export function getAggregateChartSql({
sb.from = `( sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
sb.where, sb.where,
' AND ', ' AND '
)} )}
ORDER BY profile_id, created_at DESC ORDER BY profile_id, created_at DESC
) as subQuery`; ) as subQuery`;
@@ -579,7 +647,10 @@ function isNumericColumn(columnName: string): boolean {
return numericColumns.includes(columnName); return numericColumns.includes(columnName);
} }
export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { export function getEventFiltersWhereClause(
filters: IChartEventFilter[],
projectId?: string
) {
const where: Record<string, string> = {}; const where: Record<string, string> = {};
filters.forEach((filter, index) => { filters.forEach((filter, index) => {
const id = `f${index}`; const id = `f${index}`;
@@ -602,6 +673,67 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
return; 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 ( if (
name.startsWith('properties.') || name.startsWith('properties.') ||
name.startsWith('profile.properties.') name.startsWith('profile.properties.')
@@ -616,15 +748,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map((val) => `x = ${sqlstring.escape(String(val).trim())}`) .map((val) => `x = ${sqlstring.escape(String(val).trim())}`)
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else if (value.length === 1) {
where[id] =
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
} else { } else {
if (value.length === 1) { where[id] = `${whereFrom} IN (${value
where[id] = .map((val) => sqlstring.escape(String(val).trim()))
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`; .join(', ')})`;
} else {
where[id] = `${whereFrom} IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
} }
break; break;
} }
@@ -633,15 +763,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map((val) => `x != ${sqlstring.escape(String(val).trim())}`) .map((val) => `x != ${sqlstring.escape(String(val).trim())}`)
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else if (value.length === 1) {
where[id] =
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
} else { } else {
if (value.length === 1) { where[id] = `${whereFrom} NOT IN (${value
where[id] = .map((val) => sqlstring.escape(String(val).trim()))
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`; .join(', ')})`;
} else {
where[id] = `${whereFrom} NOT IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
} }
break; break;
} }
@@ -649,15 +777,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
if (isWildcard) { if (isWildcard) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => (val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
`x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -668,14 +795,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => (val) =>
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, `x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -685,14 +812,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
if (isWildcard) { if (isWildcard) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`, (val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`, `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -702,14 +829,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
if (isWildcard) { if (isWildcard) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`, (val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`, `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -724,7 +851,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`, `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -752,14 +879,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -770,14 +897,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -788,14 +915,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -806,14 +933,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value where[id] = `arrayExists(x -> ${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')}, ${whereFrom})`; .join(' OR ')}, ${whereFrom})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -856,7 +983,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, `${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
break; break;
@@ -865,7 +992,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`, `${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
break; break;
@@ -874,7 +1001,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`, `${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
break; break;
@@ -883,7 +1010,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`, `${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
) )
.join(' OR ')})`; .join(' OR ')})`;
break; break;
@@ -892,7 +1019,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`, `match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
break; break;
@@ -902,7 +1029,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} else { } else {
@@ -917,7 +1044,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} else { } else {
@@ -932,13 +1059,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`, (val) => `${name} >= ${sqlstring.escape(String(val).trim())}`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -949,13 +1076,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => (val) =>
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`, `toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
) )
.join(' OR ')})`; .join(' OR ')})`;
} else { } else {
where[id] = `(${value where[id] = `(${value
.map( .map(
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`, (val) => `${name} <= ${sqlstring.escape(String(val).trim())}`
) )
.join(' OR ')})`; .join(' OR ')})`;
} }
@@ -974,15 +1101,15 @@ export function getChartStartEndDate(
endDate, endDate,
range, range,
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>, }: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
timezone: string, timezone: string
) { ) {
if (startDate && endDate) { if (startDate && endDate) {
return { startDate: startDate, endDate: endDate }; return { startDate, endDate };
} }
const ranges = getDatesFromRange(range, timezone); const ranges = getDatesFromRange(range, timezone);
if (!startDate && endDate) { if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate }; return { startDate: ranges.startDate, endDate };
} }
return ranges; return ranges;
@@ -1002,8 +1129,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1018,8 +1145,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1035,8 +1162,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.endOf('day') .endOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1053,8 +1180,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1071,8 +1198,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1089,8 +1216,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1106,8 +1233,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1124,8 +1251,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1141,8 +1268,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1152,8 +1279,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1170,8 +1297,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss'); .toFormat('yyyy-MM-dd HH:mm:ss');
return { return {
startDate: startDate, startDate,
endDate: endDate, endDate,
}; };
} }
@@ -1183,7 +1310,7 @@ export function getChartPrevStartEndDate({
endDate: string; endDate: string;
}) { }) {
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff( 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 // this will make sure our start and end date's are correct

View File

@@ -92,6 +92,7 @@ export interface IClickhouseEvent {
sdk_name: string; sdk_name: string;
sdk_version: string; sdk_version: string;
revenue?: number; revenue?: number;
groups: string[];
// They do not exist here. Just make ts happy for now // They do not exist here. Just make ts happy for now
profile?: IServiceProfile; profile?: IServiceProfile;
@@ -143,6 +144,7 @@ export function transformSessionToEvent(
importedAt: undefined, importedAt: undefined,
sdkName: undefined, sdkName: undefined,
sdkVersion: undefined, sdkVersion: undefined,
groups: [],
}; };
} }
@@ -179,6 +181,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
sdkVersion: event.sdk_version, sdkVersion: event.sdk_version,
profile: event.profile, profile: event.profile,
revenue: event.revenue, revenue: event.revenue,
groups: event.groups ?? [],
}; };
} }
@@ -227,6 +230,7 @@ export interface IServiceEvent {
sdkName: string | undefined; sdkName: string | undefined;
sdkVersion: string | undefined; sdkVersion: string | undefined;
revenue?: number; revenue?: number;
groups: string[];
} }
type SelectHelper<T> = { type SelectHelper<T> = {
@@ -386,6 +390,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
sdk_name: payload.sdkName ?? '', sdk_name: payload.sdkName ?? '',
sdk_version: payload.sdkVersion ?? '', sdk_version: payload.sdkVersion ?? '',
revenue: payload.revenue, revenue: payload.revenue,
groups: payload.groups ?? [],
}; };
const promises = [sessionBuffer.add(event), eventBuffer.add(event)]; const promises = [sessionBuffer.add(event), eventBuffer.add(event)];

View File

@@ -0,0 +1,147 @@
import { db } from '../prisma-client';
export type IServiceGroup = {
id: string;
projectId: string;
type: string;
name: string;
properties: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
};
export type IServiceUpsertGroup = {
id: string;
projectId: string;
type: string;
name: string;
properties?: Record<string, unknown>;
};
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<string, string>,
updatedAt: new Date(),
},
create: {
id,
projectId,
type,
name,
properties: properties as Record<string, string>,
},
});
}
export async function getGroupById(
id: string,
projectId: string
): Promise<IServiceGroup | null> {
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<string, unknown>) ?? {},
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<IServiceGroup[]> {
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<string, unknown>) ?? {},
createdAt: group.createdAt,
updatedAt: group.updatedAt,
}));
}
export async function getGroupListCount({
projectId,
type,
search,
}: {
projectId: string;
type?: string;
search?: string;
}): Promise<number> {
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<string[]> {
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 } },
});
}

View File

@@ -355,6 +355,7 @@ export async function createSessionsStartEndEvents(
profile_id: session.profile_id, profile_id: session.profile_id,
project_id: session.project_id, project_id: session.project_id,
session_id: session.session_id, session_id: session.session_id,
groups: [],
path: firstPath, path: firstPath,
origin: firstOrigin, origin: firstOrigin,
referrer: firstReferrer, referrer: firstReferrer,
@@ -390,6 +391,7 @@ export async function createSessionsStartEndEvents(
profile_id: session.profile_id, profile_id: session.profile_id,
project_id: session.project_id, project_id: session.project_id,
session_id: session.session_id, session_id: session.session_id,
groups: [],
path: lastPath, path: lastPath,
origin: lastOrigin, origin: lastOrigin,
referrer: firstReferrer, referrer: firstReferrer,

View File

@@ -3,6 +3,7 @@
import type { import type {
IAliasPayload as AliasPayload, IAliasPayload as AliasPayload,
IDecrementPayload as DecrementPayload, IDecrementPayload as DecrementPayload,
IGroupPayload as GroupPayload,
IIdentifyPayload as IdentifyPayload, IIdentifyPayload as IdentifyPayload,
IIncrementPayload as IncrementPayload, IIncrementPayload as IncrementPayload,
ITrackHandlerPayload as TrackHandlerPayload, ITrackHandlerPayload as TrackHandlerPayload,
@@ -13,6 +14,7 @@ import { Api } from './api';
export type { export type {
AliasPayload, AliasPayload,
DecrementPayload, DecrementPayload,
GroupPayload,
IdentifyPayload, IdentifyPayload,
IncrementPayload, IncrementPayload,
TrackHandlerPayload, TrackHandlerPayload,
@@ -22,8 +24,11 @@ export type {
export interface TrackProperties { export interface TrackProperties {
[key: string]: unknown; [key: string]: unknown;
profileId?: string; profileId?: string;
groups?: string[];
} }
export type GroupMetadata = Omit<GroupPayload, 'id'>;
export interface OpenPanelOptions { export interface OpenPanelOptions {
clientId: string; clientId: string;
clientSecret?: string; clientSecret?: string;
@@ -45,6 +50,7 @@ export class OpenPanel {
api: Api; api: Api;
options: OpenPanelOptions; options: OpenPanelOptions;
profileId?: string; profileId?: string;
groups: string[] = [];
deviceId?: string; deviceId?: string;
sessionId?: string; sessionId?: string;
global?: Record<string, unknown>; global?: Record<string, unknown>;
@@ -142,14 +148,19 @@ export class OpenPanel {
track(name: string, properties?: TrackProperties) { track(name: string, properties?: TrackProperties) {
this.log('track event', name, properties); this.log('track event', name, properties);
const { groups: groupsOverride, profileId, ...rest } = properties ?? {};
const mergedGroups = [
...new Set([...this.groups, ...(groupsOverride ?? [])]),
];
return this.send({ return this.send({
type: 'track', type: 'track',
payload: { payload: {
name, name,
profileId: properties?.profileId ?? this.profileId, profileId: profileId ?? this.profileId,
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
properties: { properties: {
...(this.global ?? {}), ...(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. * @deprecated This method is deprecated and will be removed in a future version.
*/ */
@@ -227,10 +259,47 @@ export class OpenPanel {
clear() { clear() {
this.profileId = undefined; this.profileId = undefined;
this.groups = [];
this.deviceId = undefined; this.deviceId = undefined;
this.sessionId = 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() { flush() {
const remaining: TrackHandlerPayload[] = []; const remaining: TrackHandlerPayload[] = [];
for (const item of this.queue) { for (const item of this.queue) {
@@ -238,16 +307,7 @@ export class OpenPanel {
remaining.push(item); remaining.push(item);
continue; continue;
} }
const payload = const payload = this.buildFlushPayload(item);
item.type === 'replay'
? item.payload
: {
...item.payload,
profileId:
'profileId' in item.payload
? (item.payload.profileId ?? this.profileId)
: this.profileId,
};
this.send({ ...item, payload } as TrackHandlerPayload); this.send({ ...item, payload } as TrackHandlerPayload);
} }
this.queue = remaining; this.queue = remaining;

View File

@@ -1,11 +1,12 @@
import { authRouter } from './routers/auth'; import { authRouter } from './routers/auth';
import { gscRouter } from './routers/gsc';
import { chartRouter } from './routers/chart'; import { chartRouter } from './routers/chart';
import { chatRouter } from './routers/chat'; import { chatRouter } from './routers/chat';
import { clientRouter } from './routers/client'; import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard'; import { dashboardRouter } from './routers/dashboard';
import { emailRouter } from './routers/email'; import { emailRouter } from './routers/email';
import { eventRouter } from './routers/event'; import { eventRouter } from './routers/event';
import { groupRouter } from './routers/group';
import { gscRouter } from './routers/gsc';
import { importRouter } from './routers/import'; import { importRouter } from './routers/import';
import { insightRouter } from './routers/insight'; import { insightRouter } from './routers/insight';
import { integrationRouter } from './routers/integration'; import { integrationRouter } from './routers/integration';
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
widget: widgetRouter, widget: widgetRouter,
email: emailRouter, email: emailRouter,
gsc: gscRouter, gsc: gscRouter,
group: groupRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -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<string>();
for (const group of groups) {
const props = group.properties as Record<string, unknown>;
for (const key of Object.keys(props)) {
keys.add(key);
}
}
return Array.from(keys).sort();
}),
});

View File

@@ -2,11 +2,19 @@ import { RESERVED_EVENT_NAMES } from '@openpanel/constants';
import { z } from 'zod'; import { z } from 'zod';
import { isBlockedEventName } from './event-blocklist'; 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 export const zTrackPayload = z
.object({ .object({
name: z.string().min(1), name: z.string().min(1),
properties: z.record(z.string(), z.unknown()).optional(), properties: z.record(z.string(), z.unknown()).optional(),
profileId: z.string().or(z.number()).optional(), profileId: z.string().or(z.number()).optional(),
groups: z.array(z.string()).optional(),
}) })
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), { .refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`, 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'), type: z.literal('replay'),
payload: zReplayPayload, payload: zReplayPayload,
}), }),
z.object({
type: z.literal('group'),
payload: zGroupPayload,
}),
]); ]);
export type ITrackPayload = z.infer<typeof zTrackPayload>; export type ITrackPayload = z.infer<typeof zTrackPayload>;
@@ -105,6 +117,7 @@ export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
export type IDecrementPayload = z.infer<typeof zDecrementPayload>; export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
export type IAliasPayload = z.infer<typeof zAliasPayload>; export type IAliasPayload = z.infer<typeof zAliasPayload>;
export type IReplayPayload = z.infer<typeof zReplayPayload>; export type IReplayPayload = z.infer<typeof zReplayPayload>;
export type IGroupPayload = z.infer<typeof zGroupPayload>;
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>; export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
// Deprecated types for beta version of the SDKs // Deprecated types for beta version of the SDKs