wip
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
getProfileById,
|
||||
getSalts,
|
||||
replayBuffer,
|
||||
upsertGroup,
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
type IGroupPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type IReplayPayload,
|
||||
@@ -218,6 +220,7 @@ async function handleTrack(
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
groups: payload.groups ?? [],
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
@@ -333,6 +336,20 @@ async function handleReplay(
|
||||
await replayBuffer.add(row);
|
||||
}
|
||||
|
||||
async function handleGroup(
|
||||
payload: IGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { id, type, name, properties = {} } = payload;
|
||||
await upsertGroup({
|
||||
id,
|
||||
projectId: context.projectId,
|
||||
type,
|
||||
name,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
@@ -381,6 +398,9 @@ export async function handler(
|
||||
case 'replay':
|
||||
await handleReplay(validatedBody.payload, context);
|
||||
break;
|
||||
case 'group':
|
||||
await handleGroup(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { chartSegments } from '@openpanel/constants';
|
||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||
import {
|
||||
ActivityIcon,
|
||||
Building2Icon,
|
||||
ClockIcon,
|
||||
EqualApproximatelyIcon,
|
||||
type LucideIcon,
|
||||
@@ -10,10 +13,7 @@ import {
|
||||
UserCheckIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { chartSegments } from '@openpanel/constants';
|
||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface ReportChartTypeProps {
|
||||
className?: string;
|
||||
@@ -46,6 +45,7 @@ export function ReportSegment({
|
||||
event: ActivityIcon,
|
||||
user: UsersIcon,
|
||||
session: ClockIcon,
|
||||
group: Building2Icon,
|
||||
user_average: UserCheck2Icon,
|
||||
one_event_per_user: UserCheckIcon,
|
||||
property_sum: SigmaIcon,
|
||||
@@ -58,9 +58,9 @@ export function ReportSegment({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Icons[value]}
|
||||
className={cn('justify-start text-sm', className)}
|
||||
icon={Icons[value]}
|
||||
variant="outline"
|
||||
>
|
||||
{items.find((item) => item.value === value)?.label}
|
||||
</Button>
|
||||
@@ -74,13 +74,13 @@ export function ReportSegment({
|
||||
const Icon = Icons[item.value];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="group"
|
||||
key={item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
className="group"
|
||||
>
|
||||
{item.label}
|
||||
<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>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Building2Icon,
|
||||
DatabaseIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,11 +21,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface PropertiesComboboxProps {
|
||||
event?: IChartEvent;
|
||||
@@ -40,15 +47,15 @@ function SearchHeader({
|
||||
return (
|
||||
<div className="row items-center gap-1">
|
||||
{!!onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<Button onClick={onBack} size="icon" variant="ghost">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
value={value}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
|
||||
exclude = [],
|
||||
}: PropertiesComboboxProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const [open, setOpen] = useState(false);
|
||||
const properties = useEventProperties({
|
||||
event: event?.name,
|
||||
projectId,
|
||||
});
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
|
||||
const groupPropertiesQuery = useQuery(
|
||||
trpc.group.properties.queryOptions({ projectId })
|
||||
);
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>(
|
||||
'index'
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile');
|
||||
setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
|
||||
}
|
||||
}, [open, mode]);
|
||||
|
||||
@@ -86,11 +99,21 @@ export function PropertiesCombobox({
|
||||
});
|
||||
};
|
||||
|
||||
// Mock data for the lists
|
||||
// Fixed group properties: name, type, plus dynamic property keys
|
||||
const groupActions = [
|
||||
{ value: 'group.name', label: 'name', description: 'group' },
|
||||
{ value: 'group.type', label: 'type', description: 'group' },
|
||||
...(groupPropertiesQuery.data ?? []).map((key) => ({
|
||||
value: `group.properties.${key}`,
|
||||
label: key,
|
||||
description: 'group.properties',
|
||||
})),
|
||||
].filter((a) => shouldShowProperty(a.value));
|
||||
|
||||
const profileActions = properties
|
||||
.filter(
|
||||
(property) =>
|
||||
property.startsWith('profile') && shouldShowProperty(property),
|
||||
property.startsWith('profile') && shouldShowProperty(property)
|
||||
)
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
|
||||
const eventActions = properties
|
||||
.filter(
|
||||
(property) =>
|
||||
!property.startsWith('profile') && shouldShowProperty(property),
|
||||
!property.startsWith('profile') && shouldShowProperty(property)
|
||||
)
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
|
||||
description: property.split('.').slice(0, -1).join('.'),
|
||||
}));
|
||||
|
||||
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
|
||||
const handleStateChange = (
|
||||
newState: 'index' | 'event' | 'profile' | 'group'
|
||||
) => {
|
||||
setDirection(newState === 'index' ? 'backward' : 'forward');
|
||||
setState(newState);
|
||||
};
|
||||
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
|
||||
}}
|
||||
>
|
||||
Event properties
|
||||
<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
|
||||
className="group justify-between gap-2"
|
||||
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
|
||||
}}
|
||||
>
|
||||
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>
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
|
||||
const filteredActions = eventActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
height={300}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
|
||||
const filteredActions = profileActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
height={300}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
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)}
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-80" align="start">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<DropdownMenuContent align="start" className="max-w-80">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{state === 'index' && (
|
||||
<motion.div
|
||||
key="index"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="index"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderIndex()}
|
||||
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
|
||||
)}
|
||||
{state === 'event' && (
|
||||
<motion.div
|
||||
key="event"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="event"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderEvent()}
|
||||
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
|
||||
)}
|
||||
{state === 'profile' && (
|
||||
<motion.div
|
||||
key="profile"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="profile"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderProfile()}
|
||||
</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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BellIcon,
|
||||
BookOpenIcon,
|
||||
Building2Icon,
|
||||
ChartLineIcon,
|
||||
ChevronDownIcon,
|
||||
CogIcon,
|
||||
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||
<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">
|
||||
Manage
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout
|
||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
|
||||
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups'
|
||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
||||
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
|
||||
@@ -63,6 +64,7 @@ import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from
|
||||
import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsGroupIdRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId'
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index'
|
||||
@@ -350,6 +352,12 @@ const AppOrganizationIdProjectIdInsightsRoute =
|
||||
path: '/insights',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsRoute =
|
||||
AppOrganizationIdProjectIdGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdDashboardsRoute =
|
||||
AppOrganizationIdProjectIdDashboardsRouteImport.update({
|
||||
id: '/dashboards',
|
||||
@@ -443,6 +451,12 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute =
|
||||
id: '/_tabs',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
|
||||
id: '/groups_/$groupId',
|
||||
path: '/groups/$groupId',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdEventsTabsRoute =
|
||||
AppOrganizationIdProjectIdEventsTabsRouteImport.update({
|
||||
id: '/_tabs',
|
||||
@@ -615,6 +629,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -630,6 +645,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
||||
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
|
||||
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
|
||||
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
|
||||
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren
|
||||
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
||||
@@ -688,6 +704,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -703,6 +720,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
|
||||
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
||||
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
|
||||
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
||||
@@ -760,6 +778,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -779,6 +798,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||
'/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren
|
||||
'/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
|
||||
'/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
|
||||
'/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
|
||||
@@ -845,6 +865,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
@@ -860,6 +881,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/'
|
||||
| '/$organizationId/$projectId/dashboards/$dashboardId'
|
||||
| '/$organizationId/$projectId/events'
|
||||
| '/$organizationId/$projectId/groups/$groupId'
|
||||
| '/$organizationId/$projectId/notifications'
|
||||
| '/$organizationId/$projectId/profiles'
|
||||
| '/$organizationId/$projectId/reports/$reportId'
|
||||
@@ -918,6 +940,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
@@ -933,6 +956,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId'
|
||||
| '/$organizationId/$projectId/dashboards/$dashboardId'
|
||||
| '/$organizationId/$projectId/events'
|
||||
| '/$organizationId/$projectId/groups/$groupId'
|
||||
| '/$organizationId/$projectId/notifications'
|
||||
| '/$organizationId/$projectId/profiles'
|
||||
| '/$organizationId/$projectId/reports/$reportId'
|
||||
@@ -989,6 +1013,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/'
|
||||
| '/_app/$organizationId/$projectId/chat'
|
||||
| '/_app/$organizationId/$projectId/dashboards'
|
||||
| '/_app/$organizationId/$projectId/groups'
|
||||
| '/_app/$organizationId/$projectId/insights'
|
||||
| '/_app/$organizationId/$projectId/pages'
|
||||
| '/_app/$organizationId/$projectId/realtime'
|
||||
@@ -1008,6 +1033,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/dashboards_/$dashboardId'
|
||||
| '/_app/$organizationId/$projectId/events'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs'
|
||||
| '/_app/$organizationId/$projectId/groups_/$groupId'
|
||||
| '/_app/$organizationId/$projectId/notifications'
|
||||
| '/_app/$organizationId/$projectId/notifications/_tabs'
|
||||
| '/_app/$organizationId/$projectId/profiles'
|
||||
@@ -1378,6 +1404,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups': {
|
||||
id: '/_app/$organizationId/$projectId/groups'
|
||||
path: '/groups'
|
||||
fullPath: '/$organizationId/$projectId/groups'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/dashboards': {
|
||||
id: '/_app/$organizationId/$projectId/dashboards'
|
||||
path: '/dashboards'
|
||||
@@ -1490,6 +1523,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId': {
|
||||
id: '/_app/$organizationId/$projectId/groups_/$groupId'
|
||||
path: '/groups/$groupId'
|
||||
fullPath: '/$organizationId/$projectId/groups/$groupId'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/events/_tabs': {
|
||||
id: '/_app/$organizationId/$projectId/events/_tabs'
|
||||
path: '/events'
|
||||
@@ -1875,6 +1915,7 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
|
||||
interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
|
||||
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
|
||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -1885,6 +1926,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
||||
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||
AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
|
||||
AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
|
||||
AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
|
||||
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
||||
@@ -1897,6 +1939,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
|
||||
AppOrganizationIdProjectIdDashboardsRoute:
|
||||
AppOrganizationIdProjectIdDashboardsRoute,
|
||||
AppOrganizationIdProjectIdGroupsRoute:
|
||||
AppOrganizationIdProjectIdGroupsRoute,
|
||||
AppOrganizationIdProjectIdInsightsRoute:
|
||||
AppOrganizationIdProjectIdInsightsRoute,
|
||||
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
|
||||
@@ -1914,6 +1958,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdDashboardsDashboardIdRoute,
|
||||
AppOrganizationIdProjectIdEventsRoute:
|
||||
AppOrganizationIdProjectIdEventsRouteWithChildren,
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute,
|
||||
AppOrganizationIdProjectIdNotificationsRoute:
|
||||
AppOrganizationIdProjectIdNotificationsRouteWithChildren,
|
||||
AppOrganizationIdProjectIdProfilesRoute:
|
||||
|
||||
146
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
146
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const BASE_TITLE = 'OpenPanel.dev';
|
||||
export function createTitle(
|
||||
pageTitle: string,
|
||||
section?: string,
|
||||
baseTitle = BASE_TITLE,
|
||||
baseTitle = BASE_TITLE
|
||||
): string {
|
||||
const parts = [pageTitle];
|
||||
if (section) {
|
||||
@@ -25,7 +25,7 @@ export function createTitle(
|
||||
*/
|
||||
export function createOrganizationTitle(
|
||||
pageTitle: string,
|
||||
organizationName?: string,
|
||||
organizationName?: string
|
||||
): string {
|
||||
if (organizationName) {
|
||||
return createTitle(pageTitle, organizationName);
|
||||
@@ -39,7 +39,7 @@ export function createOrganizationTitle(
|
||||
export function createProjectTitle(
|
||||
pageTitle: string,
|
||||
projectName?: string,
|
||||
organizationName?: string,
|
||||
organizationName?: string
|
||||
): string {
|
||||
const parts = [pageTitle];
|
||||
if (projectName) {
|
||||
@@ -59,7 +59,7 @@ export function createEntityTitle(
|
||||
entityName: string,
|
||||
entityType: string,
|
||||
projectName?: string,
|
||||
organizationName?: string,
|
||||
organizationName?: string
|
||||
): string {
|
||||
const parts = [entityName, entityType];
|
||||
if (projectName) {
|
||||
@@ -95,6 +95,9 @@ export const PAGE_TITLES = {
|
||||
PROFILES: 'Profiles',
|
||||
PROFILE_EVENTS: 'Profile events',
|
||||
PROFILE_DETAILS: 'Profile details',
|
||||
// Groups
|
||||
GROUPS: 'Groups',
|
||||
GROUP_DETAILS: 'Group details',
|
||||
|
||||
// Sub-sections
|
||||
CONVERSIONS: 'Conversions',
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function incomingEvent(
|
||||
__hash: hash,
|
||||
__query: query,
|
||||
}),
|
||||
groups: body.groups ?? [],
|
||||
createdAt,
|
||||
duration: 0,
|
||||
sdkName,
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": "off",
|
||||
"noAccumulatingSpread": "off"
|
||||
"noAccumulatingSpread": "off",
|
||||
"noBarrelFile": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
|
||||
@@ -113,6 +113,7 @@ export const chartSegments = {
|
||||
event: 'All events',
|
||||
user: 'Unique users',
|
||||
session: 'Unique sessions',
|
||||
group: 'Unique groups',
|
||||
user_average: 'Average users',
|
||||
one_event_per_user: 'One event per user',
|
||||
property_sum: 'Sum of property',
|
||||
@@ -195,7 +196,7 @@ export const metrics = {
|
||||
} as const;
|
||||
|
||||
export function isMinuteIntervalEnabledByRange(
|
||||
range: keyof typeof timeWindows,
|
||||
range: keyof typeof timeWindows
|
||||
) {
|
||||
return range === '30min' || range === 'lastHour';
|
||||
}
|
||||
@@ -210,7 +211,7 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeWindows) {
|
||||
}
|
||||
|
||||
export function getDefaultIntervalByRange(
|
||||
range: keyof typeof timeWindows,
|
||||
range: keyof typeof timeWindows
|
||||
): keyof typeof intervals {
|
||||
if (range === '30min' || range === 'lastHour') {
|
||||
return 'minute';
|
||||
@@ -231,7 +232,7 @@ export function getDefaultIntervalByRange(
|
||||
|
||||
export function getDefaultIntervalByDates(
|
||||
startDate: string | null,
|
||||
endDate: string | null,
|
||||
endDate: string | null
|
||||
): null | keyof typeof intervals {
|
||||
if (startDate && endDate) {
|
||||
if (isSameDay(startDate, endDate)) {
|
||||
|
||||
69
packages/db/code-migrations/11-add-groups.ts
Normal file
69
packages/db/code-migrations/11-add-groups.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
export * from './src/prisma-client';
|
||||
export * from './src/clickhouse/client';
|
||||
export * from './src/sql-builder';
|
||||
export * from './src/services/chart.service';
|
||||
export * from './src/engine';
|
||||
export * from './src/services/clients.service';
|
||||
export * from './src/services/dashboard.service';
|
||||
export * from './src/services/event.service';
|
||||
export * from './src/services/organization.service';
|
||||
export * from './src/services/profile.service';
|
||||
export * from './src/services/project.service';
|
||||
export * from './src/services/reports.service';
|
||||
export * from './src/services/salt.service';
|
||||
export * from './src/services/share.service';
|
||||
export * from './src/services/session.service';
|
||||
export * from './src/services/funnel.service';
|
||||
export * from './src/services/conversion.service';
|
||||
export * from './src/services/sankey.service';
|
||||
export * from './src/services/user.service';
|
||||
export * from './src/services/reference.service';
|
||||
export * from './src/services/id.service';
|
||||
export * from './src/services/retention.service';
|
||||
export * from './src/services/notification.service';
|
||||
export * from './src/services/access.service';
|
||||
export * from './src/services/delete.service';
|
||||
export * from './src/buffers';
|
||||
export * from './src/types';
|
||||
export * from './src/clickhouse/client';
|
||||
export * from './src/clickhouse/query-builder';
|
||||
export * from './src/encryption';
|
||||
export * from './src/engine';
|
||||
export * from './src/engine';
|
||||
export * from './src/gsc';
|
||||
export * from './src/prisma-client';
|
||||
export * from './src/services/access.service';
|
||||
export * from './src/services/chart.service';
|
||||
export * from './src/services/clients.service';
|
||||
export * from './src/services/conversion.service';
|
||||
export * from './src/services/dashboard.service';
|
||||
export * from './src/services/delete.service';
|
||||
export * from './src/services/event.service';
|
||||
export * from './src/services/funnel.service';
|
||||
export * from './src/services/group.service';
|
||||
export * from './src/services/id.service';
|
||||
export * from './src/services/import.service';
|
||||
export * from './src/services/insights';
|
||||
export * from './src/services/notification.service';
|
||||
export * from './src/services/organization.service';
|
||||
export * from './src/services/overview.service';
|
||||
export * from './src/services/pages.service';
|
||||
export * from './src/services/insights';
|
||||
export * from './src/services/profile.service';
|
||||
export * from './src/services/project.service';
|
||||
export * from './src/services/reference.service';
|
||||
export * from './src/services/reports.service';
|
||||
export * from './src/services/retention.service';
|
||||
export * from './src/services/salt.service';
|
||||
export * from './src/services/sankey.service';
|
||||
export * from './src/services/session.service';
|
||||
export * from './src/services/share.service';
|
||||
export * from './src/services/user.service';
|
||||
export * from './src/session-context';
|
||||
export * from './src/gsc';
|
||||
export * from './src/encryption';
|
||||
export * from './src/sql-builder';
|
||||
export * from './src/types';
|
||||
|
||||
@@ -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;
|
||||
@@ -199,6 +199,7 @@ model Project {
|
||||
meta EventMeta[]
|
||||
references Reference[]
|
||||
access ProjectAccess[]
|
||||
groups Group[]
|
||||
|
||||
notificationRules NotificationRule[]
|
||||
notifications Notification[]
|
||||
@@ -215,6 +216,20 @@ model Project {
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @default("")
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
type String
|
||||
name String
|
||||
properties Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([projectId, id])
|
||||
@@map("groups")
|
||||
}
|
||||
|
||||
enum AccessLevel {
|
||||
read
|
||||
write
|
||||
|
||||
@@ -61,6 +61,7 @@ export const TABLE_NAMES = {
|
||||
gsc_daily: 'gsc_daily',
|
||||
gsc_pages_daily: 'gsc_pages_daily',
|
||||
gsc_queries_daily: 'gsc_queries_daily',
|
||||
groups: 'groups',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import sqlstring from 'sqlstring';
|
||||
|
||||
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
||||
import type {
|
||||
IChartEventFilter,
|
||||
IReportInput,
|
||||
IChartRange,
|
||||
IGetChartDataInput,
|
||||
IReportInput,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client';
|
||||
import sqlstring from 'sqlstring';
|
||||
import { formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client';
|
||||
import { createSqlBuilder } from '../sql-builder';
|
||||
|
||||
export function transformPropertyKey(property: string) {
|
||||
const propertyPatterns = ['properties', 'profile.properties'];
|
||||
const match = propertyPatterns.find((pattern) =>
|
||||
property.startsWith(`${pattern}.`),
|
||||
property.startsWith(`${pattern}.`)
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
@@ -32,21 +30,49 @@ export function transformPropertyKey(property: string) {
|
||||
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`;
|
||||
}
|
||||
|
||||
export function getSelectPropertyKey(property: string) {
|
||||
// Returns a SQL expression for a group property using dictGet
|
||||
// property format: "group.name", "group.type", "group.properties.plan"
|
||||
export function getGroupPropertySql(
|
||||
property: string,
|
||||
projectId: string
|
||||
): string {
|
||||
const withoutPrefix = property.replace(/^group\./, '');
|
||||
if (withoutPrefix === 'name') {
|
||||
return `dictGet('${TABLE_NAMES.groups_dict}', 'name', tuple(_group_id, ${sqlstring.escape(projectId)}))`;
|
||||
}
|
||||
if (withoutPrefix === 'type') {
|
||||
return `dictGet('${TABLE_NAMES.groups_dict}', 'type', tuple(_group_id, ${sqlstring.escape(projectId)}))`;
|
||||
}
|
||||
if (withoutPrefix.startsWith('properties.')) {
|
||||
const propKey = withoutPrefix.replace(/^properties\./, '');
|
||||
// properties is stored as JSON string in dict; use JSONExtractString
|
||||
return `JSONExtractString(dictGet('${TABLE_NAMES.groups_dict}', 'properties', tuple(_group_id, ${sqlstring.escape(projectId)})), ${sqlstring.escape(propKey)})`;
|
||||
}
|
||||
return '_group_id';
|
||||
}
|
||||
|
||||
export function getSelectPropertyKey(property: string, projectId?: string) {
|
||||
if (property === 'has_profile') {
|
||||
return `if(profile_id != device_id, 'true', 'false')`;
|
||||
}
|
||||
|
||||
// Handle group properties — requires ARRAY JOIN to be present in query
|
||||
if (property.startsWith('group.') && projectId) {
|
||||
return getGroupPropertySql(property, projectId);
|
||||
}
|
||||
|
||||
const propertyPatterns = ['properties', 'profile.properties'];
|
||||
|
||||
const match = propertyPatterns.find((pattern) =>
|
||||
property.startsWith(`${pattern}.`),
|
||||
property.startsWith(`${pattern}.`)
|
||||
);
|
||||
if (!match) return property;
|
||||
if (!match) {
|
||||
return property;
|
||||
}
|
||||
|
||||
if (property.includes('*')) {
|
||||
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape(
|
||||
transformPropertyKey(property),
|
||||
transformPropertyKey(property)
|
||||
)})))`;
|
||||
}
|
||||
|
||||
@@ -78,7 +104,7 @@ export function getChartSql({
|
||||
with: addCte,
|
||||
} = createSqlBuilder();
|
||||
|
||||
sb.where = getEventFiltersWhereClause(event.filters);
|
||||
sb.where = getEventFiltersWhereClause(event.filters, projectId);
|
||||
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||
|
||||
if (event.name !== '*') {
|
||||
@@ -89,11 +115,23 @@ export function getChartSql({
|
||||
}
|
||||
|
||||
const anyFilterOnProfile = event.filters.some((filter) =>
|
||||
filter.name.startsWith('profile.'),
|
||||
filter.name.startsWith('profile.')
|
||||
);
|
||||
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
||||
breakdown.name.startsWith('profile.'),
|
||||
breakdown.name.startsWith('profile.')
|
||||
);
|
||||
const anyFilterOnGroup = event.filters.some((filter) =>
|
||||
filter.name.startsWith('group.')
|
||||
);
|
||||
const anyBreakdownOnGroup = breakdowns.some((breakdown) =>
|
||||
breakdown.name.startsWith('group.')
|
||||
);
|
||||
const needsGroupArrayJoin =
|
||||
anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group';
|
||||
|
||||
if (needsGroupArrayJoin) {
|
||||
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||
}
|
||||
|
||||
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
|
||||
// Define this early so we can use it in CTE definitions
|
||||
@@ -178,8 +216,8 @@ export function getChartSql({
|
||||
addCte(
|
||||
'profile',
|
||||
`SELECT ${selectFields.join(', ')}
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}`,
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||
);
|
||||
|
||||
// Use the CTE reference in the main query
|
||||
@@ -228,28 +266,33 @@ export function getChartSql({
|
||||
// Use CTE to define top breakdown values once, then reference in WHERE clause
|
||||
if (breakdowns.length > 0 && limit) {
|
||||
const breakdownSelects = breakdowns
|
||||
.map((b) => getSelectPropertyKey(b.name))
|
||||
.map((b) => getSelectPropertyKey(b.name, projectId))
|
||||
.join(', ');
|
||||
|
||||
const groupArrayJoinClause = needsGroupArrayJoin
|
||||
? 'ARRAY JOIN groups AS _group_id'
|
||||
: '';
|
||||
|
||||
// Add top_breakdowns CTE using the builder
|
||||
addCte(
|
||||
'top_breakdowns',
|
||||
`SELECT ${breakdownSelects}
|
||||
FROM ${TABLE_NAMES.events} e
|
||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
|
||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()}
|
||||
GROUP BY ${breakdownSelects}
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT ${limit}`,
|
||||
LIMIT ${limit}`
|
||||
);
|
||||
|
||||
// Filter main query to only include top breakdown values
|
||||
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
|
||||
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
|
||||
}
|
||||
|
||||
breakdowns.forEach((breakdown, index) => {
|
||||
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
||||
const key = `label_${index + 1}`;
|
||||
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
|
||||
sb.select[key] =
|
||||
`${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`;
|
||||
sb.groupBy[key] = `${key}`;
|
||||
});
|
||||
|
||||
@@ -261,6 +304,10 @@ export function getChartSql({
|
||||
sb.select.count = 'countDistinct(session_id) as count';
|
||||
}
|
||||
|
||||
if (event.segment === 'group') {
|
||||
sb.select.count = 'countDistinct(_group_id) as count';
|
||||
}
|
||||
|
||||
if (event.segment === 'user_average') {
|
||||
sb.select.count =
|
||||
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
|
||||
@@ -289,7 +336,7 @@ export function getChartSql({
|
||||
sb.from = `(
|
||||
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
|
||||
sb.where,
|
||||
' AND ',
|
||||
' AND '
|
||||
)}
|
||||
ORDER BY profile_id, created_at DESC
|
||||
) as subQuery`;
|
||||
@@ -308,7 +355,7 @@ export function getChartSql({
|
||||
// Since outer query groups by label_X, we reference those in the correlation
|
||||
const breakdownMatches = breakdowns
|
||||
.map((b, index) => {
|
||||
const propertyKey = getSelectPropertyKey(b.name);
|
||||
const propertyKey = getSelectPropertyKey(b.name, projectId);
|
||||
// Correlate: match the property expression with outer query's label_X value
|
||||
// ClickHouse allows referencing outer query columns in correlated subqueries
|
||||
return `${propertyKey} = label_${index + 1}`;
|
||||
@@ -359,7 +406,7 @@ export function getAggregateChartSql({
|
||||
}) {
|
||||
const { sb, join, getJoins, with: addCte, getSql } = createSqlBuilder();
|
||||
|
||||
sb.where = getEventFiltersWhereClause(event.filters);
|
||||
sb.where = getEventFiltersWhereClause(event.filters, projectId);
|
||||
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||
|
||||
if (event.name !== '*') {
|
||||
@@ -370,11 +417,23 @@ export function getAggregateChartSql({
|
||||
}
|
||||
|
||||
const anyFilterOnProfile = event.filters.some((filter) =>
|
||||
filter.name.startsWith('profile.'),
|
||||
filter.name.startsWith('profile.')
|
||||
);
|
||||
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
||||
breakdown.name.startsWith('profile.'),
|
||||
breakdown.name.startsWith('profile.')
|
||||
);
|
||||
const anyFilterOnGroup = event.filters.some((filter) =>
|
||||
filter.name.startsWith('group.')
|
||||
);
|
||||
const anyBreakdownOnGroup = breakdowns.some((breakdown) =>
|
||||
breakdown.name.startsWith('group.')
|
||||
);
|
||||
const needsGroupArrayJoin =
|
||||
anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group';
|
||||
|
||||
if (needsGroupArrayJoin) {
|
||||
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
|
||||
}
|
||||
|
||||
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
|
||||
const getWhereWithoutBar = () => {
|
||||
@@ -455,8 +514,8 @@ export function getAggregateChartSql({
|
||||
addCte(
|
||||
'profile',
|
||||
`SELECT ${selectFields.join(', ')}
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}`,
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}`
|
||||
);
|
||||
|
||||
sb.joins.profiles = profilesJoinRef;
|
||||
@@ -478,28 +537,33 @@ export function getAggregateChartSql({
|
||||
// Use CTE to define top breakdown values once, then reference in WHERE clause
|
||||
if (breakdowns.length > 0 && limit) {
|
||||
const breakdownSelects = breakdowns
|
||||
.map((b) => getSelectPropertyKey(b.name))
|
||||
.map((b) => getSelectPropertyKey(b.name, projectId))
|
||||
.join(', ');
|
||||
|
||||
const groupArrayJoinClause = needsGroupArrayJoin
|
||||
? 'ARRAY JOIN groups AS _group_id'
|
||||
: '';
|
||||
|
||||
addCte(
|
||||
'top_breakdowns',
|
||||
`SELECT ${breakdownSelects}
|
||||
FROM ${TABLE_NAMES.events} e
|
||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
|
||||
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()}
|
||||
GROUP BY ${breakdownSelects}
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT ${limit}`,
|
||||
LIMIT ${limit}`
|
||||
);
|
||||
|
||||
// Filter main query to only include top breakdown values
|
||||
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
|
||||
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
|
||||
}
|
||||
|
||||
// Add breakdowns to SELECT and GROUP BY
|
||||
breakdowns.forEach((breakdown, index) => {
|
||||
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
||||
const key = `label_${index + 1}`;
|
||||
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
|
||||
sb.select[key] =
|
||||
`${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`;
|
||||
sb.groupBy[key] = `${key}`;
|
||||
});
|
||||
|
||||
@@ -518,6 +582,10 @@ export function getAggregateChartSql({
|
||||
sb.select.count = 'countDistinct(session_id) as count';
|
||||
}
|
||||
|
||||
if (event.segment === 'group') {
|
||||
sb.select.count = 'countDistinct(_group_id) as count';
|
||||
}
|
||||
|
||||
if (event.segment === 'user_average') {
|
||||
sb.select.count =
|
||||
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
|
||||
@@ -531,7 +599,7 @@ export function getAggregateChartSql({
|
||||
}[event.segment as string];
|
||||
|
||||
if (mathFunction && event.property) {
|
||||
const propertyKey = getSelectPropertyKey(event.property);
|
||||
const propertyKey = getSelectPropertyKey(event.property, projectId);
|
||||
|
||||
if (isNumericColumn(event.property)) {
|
||||
sb.select.count = `${mathFunction}(${propertyKey}) as count`;
|
||||
@@ -546,7 +614,7 @@ export function getAggregateChartSql({
|
||||
sb.from = `(
|
||||
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
|
||||
sb.where,
|
||||
' AND ',
|
||||
' AND '
|
||||
)}
|
||||
ORDER BY profile_id, created_at DESC
|
||||
) as subQuery`;
|
||||
@@ -579,7 +647,10 @@ function isNumericColumn(columnName: string): boolean {
|
||||
return numericColumns.includes(columnName);
|
||||
}
|
||||
|
||||
export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
export function getEventFiltersWhereClause(
|
||||
filters: IChartEventFilter[],
|
||||
projectId?: string
|
||||
) {
|
||||
const where: Record<string, string> = {};
|
||||
filters.forEach((filter, index) => {
|
||||
const id = `f${index}`;
|
||||
@@ -602,6 +673,67 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle group. prefixed filters using dictGet (requires ARRAY JOIN in query)
|
||||
if (name.startsWith('group.') && projectId) {
|
||||
const whereFrom = getGroupPropertySql(name, projectId);
|
||||
switch (operator) {
|
||||
case 'is': {
|
||||
if (value.length === 1) {
|
||||
where[id] =
|
||||
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
|
||||
} else {
|
||||
where[id] =
|
||||
`${whereFrom} IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'isNot': {
|
||||
if (value.length === 1) {
|
||||
where[id] =
|
||||
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
|
||||
} else {
|
||||
where[id] =
|
||||
`${whereFrom} NOT IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'contains': {
|
||||
where[id] =
|
||||
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`;
|
||||
break;
|
||||
}
|
||||
case 'doesNotContain': {
|
||||
where[id] =
|
||||
`(${value.map((val) => `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`;
|
||||
break;
|
||||
}
|
||||
case 'startsWith': {
|
||||
where[id] =
|
||||
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`).join(' OR ')})`;
|
||||
break;
|
||||
}
|
||||
case 'endsWith': {
|
||||
where[id] =
|
||||
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`).join(' OR ')})`;
|
||||
break;
|
||||
}
|
||||
case 'isNull': {
|
||||
where[id] = `(${whereFrom} = '' OR ${whereFrom} IS NULL)`;
|
||||
break;
|
||||
}
|
||||
case 'isNotNull': {
|
||||
where[id] = `(${whereFrom} != '' AND ${whereFrom} IS NOT NULL)`;
|
||||
break;
|
||||
}
|
||||
case 'regex': {
|
||||
where[id] =
|
||||
`(${value.map((val) => `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`).join(' OR ')})`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
name.startsWith('properties.') ||
|
||||
name.startsWith('profile.properties.')
|
||||
@@ -616,15 +748,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map((val) => `x = ${sqlstring.escape(String(val).trim())}`)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else if (value.length === 1) {
|
||||
where[id] =
|
||||
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
|
||||
} else {
|
||||
if (value.length === 1) {
|
||||
where[id] =
|
||||
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
|
||||
} else {
|
||||
where[id] = `${whereFrom} IN (${value
|
||||
.map((val) => sqlstring.escape(String(val).trim()))
|
||||
.join(', ')})`;
|
||||
}
|
||||
where[id] = `${whereFrom} IN (${value
|
||||
.map((val) => sqlstring.escape(String(val).trim()))
|
||||
.join(', ')})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -633,15 +763,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map((val) => `x != ${sqlstring.escape(String(val).trim())}`)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else if (value.length === 1) {
|
||||
where[id] =
|
||||
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
|
||||
} else {
|
||||
if (value.length === 1) {
|
||||
where[id] =
|
||||
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
|
||||
} else {
|
||||
where[id] = `${whereFrom} NOT IN (${value
|
||||
.map((val) => sqlstring.escape(String(val).trim()))
|
||||
.join(', ')})`;
|
||||
}
|
||||
where[id] = `${whereFrom} NOT IN (${value
|
||||
.map((val) => sqlstring.escape(String(val).trim()))
|
||||
.join(', ')})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -649,15 +777,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
if (isWildcard) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) =>
|
||||
`x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
||||
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
||||
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -668,14 +795,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) =>
|
||||
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
||||
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
||||
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -685,14 +812,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
if (isWildcard) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
|
||||
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
|
||||
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -702,14 +829,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
if (isWildcard) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
|
||||
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
|
||||
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -724,7 +851,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`,
|
||||
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -752,14 +879,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -770,14 +897,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -788,14 +915,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -806,14 +933,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `arrayExists(x -> ${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')}, ${whereFrom})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -856,7 +983,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
||||
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
break;
|
||||
@@ -865,7 +992,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
|
||||
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
break;
|
||||
@@ -874,7 +1001,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
|
||||
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
break;
|
||||
@@ -883,7 +1010,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
|
||||
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
break;
|
||||
@@ -892,7 +1019,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`,
|
||||
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
break;
|
||||
@@ -902,7 +1029,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
} else {
|
||||
@@ -917,7 +1044,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
} else {
|
||||
@@ -932,13 +1059,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`,
|
||||
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -949,13 +1076,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) =>
|
||||
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
|
||||
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
} else {
|
||||
where[id] = `(${value
|
||||
.map(
|
||||
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`,
|
||||
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`
|
||||
)
|
||||
.join(' OR ')})`;
|
||||
}
|
||||
@@ -974,15 +1101,15 @@ export function getChartStartEndDate(
|
||||
endDate,
|
||||
range,
|
||||
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
|
||||
timezone: string,
|
||||
timezone: string
|
||||
) {
|
||||
if (startDate && endDate) {
|
||||
return { startDate: startDate, endDate: endDate };
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
const ranges = getDatesFromRange(range, timezone);
|
||||
if (!startDate && endDate) {
|
||||
return { startDate: ranges.startDate, endDate: endDate };
|
||||
return { startDate: ranges.startDate, endDate };
|
||||
}
|
||||
|
||||
return ranges;
|
||||
@@ -1002,8 +1129,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1018,8 +1145,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1035,8 +1162,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.endOf('day')
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1053,8 +1180,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1071,8 +1198,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1089,8 +1216,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1106,8 +1233,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1124,8 +1251,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1141,8 +1268,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1152,8 +1279,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1170,8 +1297,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1183,7 +1310,7 @@ export function getChartPrevStartEndDate({
|
||||
endDate: string;
|
||||
}) {
|
||||
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff(
|
||||
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss'),
|
||||
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss')
|
||||
);
|
||||
|
||||
// this will make sure our start and end date's are correct
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface IClickhouseEvent {
|
||||
sdk_name: string;
|
||||
sdk_version: string;
|
||||
revenue?: number;
|
||||
groups: string[];
|
||||
|
||||
// They do not exist here. Just make ts happy for now
|
||||
profile?: IServiceProfile;
|
||||
@@ -143,6 +144,7 @@ export function transformSessionToEvent(
|
||||
importedAt: undefined,
|
||||
sdkName: undefined,
|
||||
sdkVersion: undefined,
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +181,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
sdkVersion: event.sdk_version,
|
||||
profile: event.profile,
|
||||
revenue: event.revenue,
|
||||
groups: event.groups ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,6 +230,7 @@ export interface IServiceEvent {
|
||||
sdkName: string | undefined;
|
||||
sdkVersion: string | undefined;
|
||||
revenue?: number;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
type SelectHelper<T> = {
|
||||
@@ -386,6 +390,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
sdk_name: payload.sdkName ?? '',
|
||||
sdk_version: payload.sdkVersion ?? '',
|
||||
revenue: payload.revenue,
|
||||
groups: payload.groups ?? [],
|
||||
};
|
||||
|
||||
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
|
||||
|
||||
147
packages/db/src/services/group.service.ts
Normal file
147
packages/db/src/services/group.service.ts
Normal 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 } },
|
||||
});
|
||||
}
|
||||
@@ -355,6 +355,7 @@ export async function createSessionsStartEndEvents(
|
||||
profile_id: session.profile_id,
|
||||
project_id: session.project_id,
|
||||
session_id: session.session_id,
|
||||
groups: [],
|
||||
path: firstPath,
|
||||
origin: firstOrigin,
|
||||
referrer: firstReferrer,
|
||||
@@ -390,6 +391,7 @@ export async function createSessionsStartEndEvents(
|
||||
profile_id: session.profile_id,
|
||||
project_id: session.project_id,
|
||||
session_id: session.session_id,
|
||||
groups: [],
|
||||
path: lastPath,
|
||||
origin: lastOrigin,
|
||||
referrer: firstReferrer,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {
|
||||
IAliasPayload as AliasPayload,
|
||||
IDecrementPayload as DecrementPayload,
|
||||
IGroupPayload as GroupPayload,
|
||||
IIdentifyPayload as IdentifyPayload,
|
||||
IIncrementPayload as IncrementPayload,
|
||||
ITrackHandlerPayload as TrackHandlerPayload,
|
||||
@@ -13,6 +14,7 @@ import { Api } from './api';
|
||||
export type {
|
||||
AliasPayload,
|
||||
DecrementPayload,
|
||||
GroupPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
@@ -22,8 +24,11 @@ export type {
|
||||
export interface TrackProperties {
|
||||
[key: string]: unknown;
|
||||
profileId?: string;
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export type GroupMetadata = Omit<GroupPayload, 'id'>;
|
||||
|
||||
export interface OpenPanelOptions {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
@@ -45,6 +50,7 @@ export class OpenPanel {
|
||||
api: Api;
|
||||
options: OpenPanelOptions;
|
||||
profileId?: string;
|
||||
groups: string[] = [];
|
||||
deviceId?: string;
|
||||
sessionId?: string;
|
||||
global?: Record<string, unknown>;
|
||||
@@ -142,14 +148,19 @@ export class OpenPanel {
|
||||
|
||||
track(name: string, properties?: TrackProperties) {
|
||||
this.log('track event', name, properties);
|
||||
const { groups: groupsOverride, profileId, ...rest } = properties ?? {};
|
||||
const mergedGroups = [
|
||||
...new Set([...this.groups, ...(groupsOverride ?? [])]),
|
||||
];
|
||||
return this.send({
|
||||
type: 'track',
|
||||
payload: {
|
||||
name,
|
||||
profileId: properties?.profileId ?? this.profileId,
|
||||
profileId: profileId ?? this.profileId,
|
||||
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
|
||||
properties: {
|
||||
...(this.global ?? {}),
|
||||
...(properties ?? {}),
|
||||
...rest,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -176,6 +187,27 @@ export class OpenPanel {
|
||||
}
|
||||
}
|
||||
|
||||
setGroups(groupIds: string[]) {
|
||||
this.log('set groups', groupIds);
|
||||
this.groups = groupIds;
|
||||
}
|
||||
|
||||
setGroup(groupId: string, metadata?: GroupMetadata) {
|
||||
this.log('set group', groupId, metadata);
|
||||
if (!this.groups.includes(groupId)) {
|
||||
this.groups = [...this.groups, groupId];
|
||||
}
|
||||
if (metadata) {
|
||||
return this.send({
|
||||
type: 'group',
|
||||
payload: {
|
||||
id: groupId,
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This method is deprecated and will be removed in a future version.
|
||||
*/
|
||||
@@ -227,10 +259,47 @@ export class OpenPanel {
|
||||
|
||||
clear() {
|
||||
this.profileId = undefined;
|
||||
this.groups = [];
|
||||
this.deviceId = undefined;
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
|
||||
private buildFlushPayload(
|
||||
item: TrackHandlerPayload
|
||||
): TrackHandlerPayload['payload'] {
|
||||
if (item.type === 'replay') {
|
||||
return item.payload;
|
||||
}
|
||||
if (item.type === 'track') {
|
||||
const queuedGroups =
|
||||
'groups' in item.payload ? (item.payload.groups ?? []) : [];
|
||||
const mergedGroups = [...new Set([...this.groups, ...queuedGroups])];
|
||||
return {
|
||||
...item.payload,
|
||||
profileId:
|
||||
'profileId' in item.payload
|
||||
? (item.payload.profileId ?? this.profileId)
|
||||
: this.profileId,
|
||||
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
|
||||
};
|
||||
}
|
||||
if (
|
||||
item.type === 'identify' ||
|
||||
item.type === 'increment' ||
|
||||
item.type === 'decrement'
|
||||
) {
|
||||
return {
|
||||
...item.payload,
|
||||
profileId: String(
|
||||
'profileId' in item.payload
|
||||
? (item.payload.profileId ?? this.profileId)
|
||||
: (this.profileId ?? '')
|
||||
),
|
||||
} as TrackHandlerPayload['payload'];
|
||||
}
|
||||
return item.payload;
|
||||
}
|
||||
|
||||
flush() {
|
||||
const remaining: TrackHandlerPayload[] = [];
|
||||
for (const item of this.queue) {
|
||||
@@ -238,16 +307,7 @@ export class OpenPanel {
|
||||
remaining.push(item);
|
||||
continue;
|
||||
}
|
||||
const payload =
|
||||
item.type === 'replay'
|
||||
? item.payload
|
||||
: {
|
||||
...item.payload,
|
||||
profileId:
|
||||
'profileId' in item.payload
|
||||
? (item.payload.profileId ?? this.profileId)
|
||||
: this.profileId,
|
||||
};
|
||||
const payload = this.buildFlushPayload(item);
|
||||
this.send({ ...item, payload } as TrackHandlerPayload);
|
||||
}
|
||||
this.queue = remaining;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { authRouter } from './routers/auth';
|
||||
import { gscRouter } from './routers/gsc';
|
||||
import { chartRouter } from './routers/chart';
|
||||
import { chatRouter } from './routers/chat';
|
||||
import { clientRouter } from './routers/client';
|
||||
import { dashboardRouter } from './routers/dashboard';
|
||||
import { emailRouter } from './routers/email';
|
||||
import { eventRouter } from './routers/event';
|
||||
import { groupRouter } from './routers/group';
|
||||
import { gscRouter } from './routers/gsc';
|
||||
import { importRouter } from './routers/import';
|
||||
import { insightRouter } from './routers/insight';
|
||||
import { integrationRouter } from './routers/integration';
|
||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
||||
widget: widgetRouter,
|
||||
email: emailRouter,
|
||||
gsc: gscRouter,
|
||||
group: groupRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
125
packages/trpc/src/routers/group.ts
Normal file
125
packages/trpc/src/routers/group.ts
Normal 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();
|
||||
}),
|
||||
});
|
||||
@@ -2,11 +2,19 @@ import { RESERVED_EVENT_NAMES } from '@openpanel/constants';
|
||||
import { z } from 'zod';
|
||||
import { isBlockedEventName } from './event-blocklist';
|
||||
|
||||
export const zGroupPayload = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const zTrackPayload = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
profileId: z.string().or(z.number()).optional(),
|
||||
groups: z.array(z.string()).optional(),
|
||||
})
|
||||
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
|
||||
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`,
|
||||
@@ -97,6 +105,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
||||
type: z.literal('replay'),
|
||||
payload: zReplayPayload,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('group'),
|
||||
payload: zGroupPayload,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ITrackPayload = z.infer<typeof zTrackPayload>;
|
||||
@@ -105,6 +117,7 @@ export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
|
||||
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
|
||||
export type IAliasPayload = z.infer<typeof zAliasPayload>;
|
||||
export type IReplayPayload = z.infer<typeof zReplayPayload>;
|
||||
export type IGroupPayload = z.infer<typeof zGroupPayload>;
|
||||
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
|
||||
|
||||
// Deprecated types for beta version of the SDKs
|
||||
|
||||
Reference in New Issue
Block a user