mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-12 02:27:36 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
backend/api/src/get-user-journeys.ts
Normal file
34
backend/api/src/get-user-journeys.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user