Skip to content

Commit 709488f

Browse files
committed
Add get-user-journeys API endpoint and integrate it into admin journeys page to fetch and display user events and details
1 parent 01d5d7e commit 709488f

6 files changed

Lines changed: 83 additions & 47 deletions

File tree

backend/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@compass/api",
3-
"version": "1.35.1",
3+
"version": "1.36.0",
44
"private": true,
55
"description": "Backend API endpoints",
66
"main": "src/serve.ts",

backend/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {getProfiles} from './get-profiles'
7070
import {getSupabaseToken} from './get-supabase-token'
7171
import {getUserAndProfileHandler} from './get-user-and-profile'
7272
import {getUserDataExport} from './get-user-data-export'
73+
import {getUserJourneys} from './get-user-journeys'
7374
import {hasFreeLike} from './has-free-like'
7475
import {health} from './health'
7576
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
@@ -606,6 +607,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
606607
'get-profile-answers': getProfileAnswers,
607608
'get-profiles': getProfiles,
608609
'get-supabase-token': getSupabaseToken,
610+
'get-user-journeys': getUserJourneys,
609611
'has-free-like': hasFreeLike,
610612
'hide-comment': hideComment,
611613
'hide-profile': hideProfile,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
2+
import {isAdminId} from 'common/envs/constants'
3+
import {convertUser} from 'common/supabase/users'
4+
import {createSupabaseDirectClient} from 'shared/supabase/init'
5+
6+
export const getUserJourneys: APIHandler<'get-user-journeys'> = async ({hoursFromNow}, auth) => {
7+
// Check if user is admin
8+
if (!isAdminId(auth.uid)) {
9+
throw APIErrors.forbidden('Only admins can access user journeys')
10+
}
11+
12+
const pg = createSupabaseDirectClient()
13+
14+
const start = new Date(Date.now() - parseInt(hoursFromNow) * 60 * 60 * 1000)
15+
16+
// Get users created after start time
17+
const users = await pg.any('SELECT * FROM users WHERE created_time > $1', [start.toISOString()])
18+
19+
if (users.length === 0) {
20+
return {users: [], events: []}
21+
}
22+
23+
const userIds = users.map((u) => u.id)
24+
25+
// Get events for these users
26+
const events = await pg.any('SELECT * FROM user_events WHERE user_id = ANY($1) ORDER BY ts ASC', [
27+
userIds,
28+
])
29+
30+
return {
31+
users: users.map(convertUser),
32+
events,
33+
}
34+
}

common/src/api/schema.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,20 @@ export const API = (_apiTypeCheck = {
12651265
summary: 'Extract profile information from text using LLM',
12661266
tag: 'Profiles',
12671267
},
1268+
'get-user-journeys': {
1269+
method: 'GET',
1270+
authed: true,
1271+
rateLimited: false,
1272+
props: z.object({
1273+
hoursFromNow: z.string(),
1274+
}),
1275+
returns: {} as {
1276+
users: User[]
1277+
events: Row<'user_events'>[]
1278+
},
1279+
summary: 'Get user journeys (events) for users created within the last N hours. Admin only.',
1280+
tag: 'Admin',
1281+
},
12681282
} as const)
12691283

12701284
export type APIPath = keyof typeof API

common/src/envs/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const IS_DEV = ENV === 'dev'
1919
export const ENV_CONFIG = IS_PROD ? PROD_CONFIG : DEV_CONFIG
2020

2121
export function isAdminId(id: string) {
22+
if (IS_LOCAL) return true
2223
return ENV_CONFIG.adminIds.includes(id)
2324
}
2425

web/pages/admin/journeys.tsx

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,50 @@
11
import clsx from 'clsx'
2-
import {convertUser} from 'common/supabase/users'
3-
import {Row as rowfor, run} from 'common/supabase/utils'
4-
import {User} from 'common/user'
5-
import {HOUR_MS} from 'common/util/time'
2+
import {IS_LOCAL} from 'common/hosting/constants'
3+
import {Row as rowfor} from 'common/supabase/utils'
64
import {groupBy, orderBy} from 'lodash'
7-
import {useEffect, useState} from 'react'
5+
import Router from 'next/router'
6+
import {useEffect} from 'react'
87
import {Button} from 'web/components/buttons/button'
98
import {Col} from 'web/components/layout/col'
109
import {Row} from 'web/components/layout/row'
1110
import {NoSEO} from 'web/components/NoSEO'
1211
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
1312
import {useAdmin} from 'web/hooks/use-admin'
13+
import {useAPIGetter} from 'web/hooks/use-api-getter'
1414
import {usePersistentQueryState} from 'web/hooks/use-persistent-query-state'
15-
import {useIsAuthorized} from 'web/hooks/use-user'
16-
import {db} from 'web/lib/supabase/db'
1715

1816
export default function Journeys() {
19-
const [eventsByUser, setEventsByUser] = useState<Record<string, rowfor<'user_events'>[]>>({})
2017
const [hoursFromNowQ, setHoursFromNowQ] = usePersistentQueryState('h', '5')
21-
const hoursFromNow = parseInt(hoursFromNowQ ?? '5')
22-
const [unBannedUsers, setUnBannedUsers] = useState<User[]>([])
23-
const [bannedUsers, setBannedUsers] = useState<User[]>([])
24-
const isAuthed = useIsAuthorized()
18+
const hoursFromNow = hoursFromNowQ ?? '5'
2519

26-
const getEvents = async () => {
27-
const start = Date.now() - hoursFromNow * HOUR_MS
28-
const users = await run(db.from('users').select('id').gt('data->createdTime', start))
29-
const events = await run(
30-
db
31-
.from('user_events')
32-
.select('*')
33-
.in(
34-
'user_id',
35-
users.data.map((u) => u.id),
36-
),
37-
)
38-
const eventsByUser = groupBy(
39-
orderBy(events.data as rowfor<'user_events'>[], 'ts', 'asc'),
40-
'user_id',
41-
)
20+
const {data} = useAPIGetter('get-user-journeys', {hoursFromNow})
4221

43-
setEventsByUser(eventsByUser)
44-
}
22+
const users = data?.users ?? []
23+
const events = data?.events ?? []
4524

46-
const getUsers = async () => {
47-
const userData = await run(db.from('users').select().in('id', Object.keys(eventsByUser)))
48-
const users = userData.data.map(convertUser)
49-
setBannedUsers(users.filter((u) => u.isBannedFromPosting))
50-
setUnBannedUsers(users.filter((u) => !u.isBannedFromPosting))
51-
}
25+
const bannedUsers = users.filter((u) => u.isBannedFromPosting)
26+
const unBannedUsers = users.filter((u) => !u.isBannedFromPosting)
5227

53-
useEffect(() => {
54-
getUsers()
55-
}, [JSON.stringify(Object.keys(eventsByUser))])
28+
const eventsByUser = groupBy(orderBy(events as rowfor<'user_events'>[], 'ts', 'asc'), 'user_id')
29+
30+
const isAdmin = useAdmin()
31+
32+
const authorized = isAdmin || IS_LOCAL
5633

5734
useEffect(() => {
58-
if (!isAuthed) return
59-
getEvents()
60-
}, [hoursFromNow, isAuthed])
35+
if (!authorized) Router.push('/')
36+
}, [])
6137

62-
const isAdmin = useAdmin()
63-
if (!isAdmin) return <></>
38+
if (!authorized) return <></>
6439

6540
return (
6641
<Row>
6742
<NoSEO />
6843
<div className="text-ink-900 mx-8">
6944
<div className={'text-primary-700 my-1 text-2xl'}>User Journeys</div>
7045
<Row className={'items-center gap-2'}>
71-
Viewing journeys from {unBannedUsers.length} unbanned users ({bannedUsers.length} banned).
72-
Showing users created: {hoursFromNow}h ago.
46+
Viewing journeys from {unBannedUsers.length} users. Showing users created: {hoursFromNow}h
47+
ago.
7348
<Button
7449
color={'indigo-outline'}
7550
size={'xs'}
@@ -115,11 +90,21 @@ export default function Journeys() {
11590
const timePeriod =
11691
new Date(group[times - 1].ts!).valueOf() - new Date(group[0].ts!).valueOf()
11792
const duration = Math.round(timePeriod / 1000)
93+
const data = group
94+
.map((g) => {
95+
if (!Object.keys(g.data).length) return
96+
return Object.entries(g.data)
97+
.map(([_k, v]) => `${v}`)
98+
.join(' ')
99+
})
100+
.filter(Boolean)
101+
.join('. ')
118102

119103
return (
120104
<li key={index}>
121105
{name} {times > 1 ? `${times}x` : ' '}
122106
{duration > 1 ? ` (${duration}s)` : ' '}
107+
{data && <ul>{data}</ul>}
123108
</li>
124109
)
125110
})}

0 commit comments

Comments
 (0)