From 21e51daa5f2cfd11b5230f57e733ab6355c1c8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sun, 22 Mar 2026 20:48:27 +0100 Subject: [PATCH] fix: lookup group members based on profiles table instead of events --- .../components/groups/group-member-growth.tsx | 2 +- ...projectId.groups_.$groupId._tabs.index.tsx | 78 ++-------------- packages/db/src/services/group.service.ts | 25 ++---- packages/trpc/src/routers/group.ts | 89 +++++++------------ 4 files changed, 46 insertions(+), 148 deletions(-) diff --git a/apps/start/src/components/groups/group-member-growth.tsx b/apps/start/src/components/groups/group-member-growth.tsx index a5212ec0..abd56712 100644 --- a/apps/start/src/components/groups/group-member-growth.tsx +++ b/apps/start/src/components/groups/group-member-growth.tsx @@ -81,7 +81,7 @@ export function GroupMemberGrowth({ data }: Props) { return ( - Member growth + New members last 30 days {data.length === 0 ? ( diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx index 2ed808fe..058afd3c 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx @@ -1,22 +1,18 @@ import { useSuspenseQuery } from '@tanstack/react-query'; -import { createFileRoute, Link } from '@tanstack/react-router'; -import { UsersIcon } from 'lucide-react'; +import { createFileRoute } from '@tanstack/react-router'; import FullPageLoadingState from '@/components/full-page-loading-state'; import { GroupMemberGrowth } from '@/components/groups/group-member-growth'; import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; -import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +import { WidgetHead } from '@/components/overview/overview-widget'; import { MostEvents } from '@/components/profiles/most-events'; import { PopularRoutes } from '@/components/profiles/popular-routes'; import { ProfileActivity } from '@/components/profiles/profile-activity'; import { KeyValueGrid } from '@/components/ui/key-value-grid'; -import { Widget, WidgetBody, WidgetEmptyState } from '@/components/widget'; -import { WidgetTable } from '@/components/widget-table'; +import { Widget } from '@/components/widget'; import { useTRPC } from '@/integrations/trpc/react'; -import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; +import { formatDateTime } from '@/utils/date'; import { createProjectTitle } from '@/utils/title'; -const MEMBERS_PREVIEW_LIMIT = 13; - export const Route = createFileRoute( '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/' )({ @@ -38,7 +34,7 @@ export const Route = createFileRoute( }); function Component() { - const { projectId, organizationId, groupId } = Route.useParams(); + const { projectId, groupId } = Route.useParams(); const trpc = useTRPC(); const group = useSuspenseQuery( @@ -50,9 +46,6 @@ function Component() { const activity = useSuspenseQuery( trpc.group.activity.queryOptions({ id: groupId, projectId }) ); - const members = useSuspenseQuery( - trpc.group.members.queryOptions({ id: groupId, projectId }) - ); const mostEvents = useSuspenseQuery( trpc.group.mostEvents.queryOptions({ id: groupId, projectId }) ); @@ -154,7 +147,7 @@ function Component() { - {/* Member growth */} + {/* New members last 30 days */}
@@ -169,65 +162,6 @@ function Component() { - {/* Members preview */} -
- - - Members - - - {members.data.length === 0 ? ( - - ) : ( - ( - - {member.profileId} - - ), - }, - { - key: 'events', - name: 'Events', - width: '60px', - className: 'text-muted-foreground', - render: (member) => member.eventCount, - }, - { - key: 'lastSeen', - name: 'Last Seen', - width: '150px', - className: 'text-muted-foreground', - render: (member) => - formatTimeAgoOrDateTime(new Date(member.lastSeen)), - }, - ]} - data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)} - keyExtractor={(member) => member.profileId} - /> - )} - {members.data.length > MEMBERS_PREVIEW_LIMIT && ( -

- {`${members.data.length} members found. View all in Members tab`} -

- )} -
-
-
); } diff --git a/packages/db/src/services/group.service.ts b/packages/db/src/services/group.service.ts index def8a1f0..03cb3547 100644 --- a/packages/db/src/services/group.service.ts +++ b/packages/db/src/services/group.service.ts @@ -323,32 +323,21 @@ export async function getGroupMemberProfiles({ ? `AND (email ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR first_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR last_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)})` : ''; - // count() OVER () is evaluated after JOINs/WHERE but before LIMIT, - // so we get the total match count and the paginated IDs in one query. - const rows = await chQuery<{ profile_id: string; total_count: number }>(` + const rows = await chQuery<{ id: string; total_count: number }>(` SELECT - gm.profile_id, + id, count() OVER () AS total_count - FROM ( - SELECT profile_id, max(created_at) AS last_seen - FROM ${TABLE_NAMES.events} - WHERE project_id = ${sqlstring.escape(projectId)} - AND has(groups, ${sqlstring.escape(groupId)}) - AND profile_id != device_id - GROUP BY profile_id - ) gm - INNER JOIN ( - SELECT id FROM ${TABLE_NAMES.profiles} FINAL - WHERE project_id = ${sqlstring.escape(projectId)} + FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(groupId)}) ${searchCondition} - ) p ON p.id = gm.profile_id - ORDER BY gm.last_seen DESC + ORDER BY created_at DESC LIMIT ${take} OFFSET ${offset} `); const count = rows[0]?.total_count ?? 0; - const profileIds = rows.map((r) => r.profile_id); + const profileIds = rows.map((r) => r.id); if (profileIds.length === 0) { return { data: [], count }; diff --git a/packages/trpc/src/routers/group.ts b/packages/trpc/src/routers/group.ts index adf43605..0711c941 100644 --- a/packages/trpc/src/routers/group.ts +++ b/packages/trpc/src/routers/group.ts @@ -82,27 +82,29 @@ export const groupRouter = createTRPCRouter({ metrics: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) .query(async ({ input: { id, projectId } }) => { - const data = await 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)}) - `); + const [eventData, profileData] = await Promise.all([ + chQuery<{ totalEvents: number; firstSeen: string; lastSeen: string }>(` + SELECT + count() AS totalEvents, + 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)}) + `), + chQuery<{ uniqueProfiles: number }>(` + SELECT count() AS uniqueProfiles + FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + `), + ]); return { - totalEvents: data[0]?.totalEvents ?? 0, - uniqueProfiles: data[0]?.uniqueProfiles ?? 0, - firstSeen: toNullIfDefaultMinDate(data[0]?.firstSeen), - lastSeen: toNullIfDefaultMinDate(data[0]?.lastSeen), + totalEvents: eventData[0]?.totalEvents ?? 0, + uniqueProfiles: profileData[0]?.uniqueProfiles ?? 0, + firstSeen: toNullIfDefaultMinDate(eventData[0]?.firstSeen), + lastSeen: toNullIfDefaultMinDate(eventData[0]?.lastSeen), }; }), @@ -119,25 +121,22 @@ export const groupRouter = createTRPCRouter({ `); }), - members: protectedProcedure + memberGrowth: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) .query(({ input: { id, projectId } }) => { - return chQuery<{ - profileId: string; - lastSeen: string; - eventCount: number; - }>(` + return chQuery<{ date: string; count: number }>(` SELECT - profile_id AS profileId, - max(created_at) AS lastSeen, - count() AS eventCount - FROM ${TABLE_NAMES.events} + toDate(toStartOfDay(created_at)) AS date, + count() AS count + FROM ${TABLE_NAMES.profiles} FINAL 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, eventCount DESC - LIMIT 50 + AND created_at >= now() - INTERVAL 30 DAY + GROUP BY date + ORDER BY date ASC WITH FILL + FROM toDate(now() - INTERVAL 29 DAY) + TO toDate(now() + INTERVAL 1 DAY) + STEP 1 `); }), @@ -195,30 +194,6 @@ export const groupRouter = createTRPCRouter({ `); }), - memberGrowth: protectedProcedure - .input(z.object({ id: z.string(), projectId: z.string() })) - .query(({ input: { id, projectId } }) => { - return chQuery<{ date: string; count: number }>(` - SELECT - toDate(toStartOfDay(min_date)) AS date, - count() AS count - FROM ( - SELECT profile_id, min(created_at) AS min_date - FROM ${TABLE_NAMES.events} - WHERE project_id = ${sqlstring.escape(projectId)} - AND has(groups, ${sqlstring.escape(id)}) - AND profile_id != device_id - AND created_at >= now() - INTERVAL 30 DAY - GROUP BY profile_id - ) - GROUP BY date - ORDER BY date ASC WITH FILL - FROM toDate(now() - INTERVAL 29 DAY) - TO toDate(now() + INTERVAL 1 DAY) - STEP 1 - `); - }), - properties: protectedProcedure .input(z.object({ projectId: z.string() })) .query(({ input: { projectId } }) => {