Add get-user-journeys API endpoint and integrate it into admin journeys page to fetch and display user events and details

This commit is contained in:
MartinBraquet
2026-04-09 13:12:09 +02:00
parent 01d5d7ecf9
commit 709488f919
6 changed files with 84 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.35.1",
"version": "1.36.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",

View File

@@ -70,6 +70,7 @@ import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getUserAndProfileHandler} from './get-user-and-profile'
import {getUserDataExport} from './get-user-data-export'
import {getUserJourneys} from './get-user-journeys'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
@@ -606,6 +607,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'get-profile-answers': getProfileAnswers,
'get-profiles': getProfiles,
'get-supabase-token': getSupabaseToken,
'get-user-journeys': getUserJourneys,
'has-free-like': hasFreeLike,
'hide-comment': hideComment,
'hide-profile': hideProfile,

View File

@@ -0,0 +1,34 @@
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {convertUser} from 'common/supabase/users'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getUserJourneys: APIHandler<'get-user-journeys'> = async ({hoursFromNow}, auth) => {
// Check if user is admin
if (!isAdminId(auth.uid)) {
throw APIErrors.forbidden('Only admins can access user journeys')
}
const pg = createSupabaseDirectClient()
const start = new Date(Date.now() - parseInt(hoursFromNow) * 60 * 60 * 1000)
// Get users created after start time
const users = await pg.any('SELECT * FROM users WHERE created_time > $1', [start.toISOString()])
if (users.length === 0) {
return {users: [], events: []}
}
const userIds = users.map((u) => u.id)
// Get events for these users
const events = await pg.any('SELECT * FROM user_events WHERE user_id = ANY($1) ORDER BY ts ASC', [
userIds,
])
return {
users: users.map(convertUser),
events,
}
}

View File

@@ -1265,6 +1265,20 @@ export const API = (_apiTypeCheck = {
summary: 'Extract profile information from text using LLM',
tag: 'Profiles',
},
'get-user-journeys': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
hoursFromNow: z.string(),
}),
returns: {} as {
users: User[]
events: Row<'user_events'>[]
},
summary: 'Get user journeys (events) for users created within the last N hours. Admin only.',
tag: 'Admin',
},
} as const)
export type APIPath = keyof typeof API

View File

@@ -19,6 +19,7 @@ export const IS_DEV = ENV === 'dev'
export const ENV_CONFIG = IS_PROD ? PROD_CONFIG : DEV_CONFIG
export function isAdminId(id: string) {
if (IS_LOCAL) return true
return ENV_CONFIG.adminIds.includes(id)
}

View File

@@ -1,66 +1,41 @@
import clsx from 'clsx'
import {convertUser} from 'common/supabase/users'
import {Row as rowfor, run} from 'common/supabase/utils'
import {User} from 'common/user'
import {HOUR_MS} from 'common/util/time'
import {IS_LOCAL} from 'common/hosting/constants'
import {Row as rowfor} from 'common/supabase/utils'
import {groupBy, orderBy} from 'lodash'
import {useEffect, useState} from 'react'
import Router from 'next/router'
import {useEffect} from 'react'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {NoSEO} from 'web/components/NoSEO'
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
import {useAdmin} from 'web/hooks/use-admin'
import {useAPIGetter} from 'web/hooks/use-api-getter'
import {usePersistentQueryState} from 'web/hooks/use-persistent-query-state'
import {useIsAuthorized} from 'web/hooks/use-user'
import {db} from 'web/lib/supabase/db'
export default function Journeys() {
const [eventsByUser, setEventsByUser] = useState<Record<string, rowfor<'user_events'>[]>>({})
const [hoursFromNowQ, setHoursFromNowQ] = usePersistentQueryState('h', '5')
const hoursFromNow = parseInt(hoursFromNowQ ?? '5')
const [unBannedUsers, setUnBannedUsers] = useState<User[]>([])
const [bannedUsers, setBannedUsers] = useState<User[]>([])
const isAuthed = useIsAuthorized()
const hoursFromNow = hoursFromNowQ ?? '5'
const getEvents = async () => {
const start = Date.now() - hoursFromNow * HOUR_MS
const users = await run(db.from('users').select('id').gt('data->createdTime', start))
const events = await run(
db
.from('user_events')
.select('*')
.in(
'user_id',
users.data.map((u) => u.id),
),
)
const eventsByUser = groupBy(
orderBy(events.data as rowfor<'user_events'>[], 'ts', 'asc'),
'user_id',
)
const {data} = useAPIGetter('get-user-journeys', {hoursFromNow})
setEventsByUser(eventsByUser)
}
const users = data?.users ?? []
const events = data?.events ?? []
const getUsers = async () => {
const userData = await run(db.from('users').select().in('id', Object.keys(eventsByUser)))
const users = userData.data.map(convertUser)
setBannedUsers(users.filter((u) => u.isBannedFromPosting))
setUnBannedUsers(users.filter((u) => !u.isBannedFromPosting))
}
const bannedUsers = users.filter((u) => u.isBannedFromPosting)
const unBannedUsers = users.filter((u) => !u.isBannedFromPosting)
useEffect(() => {
getUsers()
}, [JSON.stringify(Object.keys(eventsByUser))])
useEffect(() => {
if (!isAuthed) return
getEvents()
}, [hoursFromNow, isAuthed])
const eventsByUser = groupBy(orderBy(events as rowfor<'user_events'>[], 'ts', 'asc'), 'user_id')
const isAdmin = useAdmin()
if (!isAdmin) return <></>
const authorized = isAdmin || IS_LOCAL
useEffect(() => {
if (!authorized) Router.push('/')
}, [])
if (!authorized) return <></>
return (
<Row>
@@ -68,8 +43,8 @@ export default function Journeys() {
<div className="text-ink-900 mx-8">
<div className={'text-primary-700 my-1 text-2xl'}>User Journeys</div>
<Row className={'items-center gap-2'}>
Viewing journeys from {unBannedUsers.length} unbanned users ({bannedUsers.length} banned).
Showing users created: {hoursFromNow}h ago.
Viewing journeys from {unBannedUsers.length} users. Showing users created: {hoursFromNow}h
ago.
<Button
color={'indigo-outline'}
size={'xs'}
@@ -115,11 +90,21 @@ export default function Journeys() {
const timePeriod =
new Date(group[times - 1].ts!).valueOf() - new Date(group[0].ts!).valueOf()
const duration = Math.round(timePeriod / 1000)
const data = group
.map((g) => {
if (!Object.keys(g.data).length) return
return Object.entries(g.data)
.map(([_k, v]) => `${v}`)
.join(' ')
})
.filter(Boolean)
.join('. ')
return (
<li key={index}>
{name} {times > 1 ? `${times}x` : ' '}
{duration > 1 ? ` (${duration}s)` : ' '}
{data && <ul>{data}</ul>}
</li>
)
})}