From 709488f919bf5b7a5fc183566fd300a4acb057e6 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 9 Apr 2026 13:12:09 +0200 Subject: [PATCH] Add `get-user-journeys` API endpoint and integrate it into admin journeys page to fetch and display user events and details --- backend/api/package.json | 2 +- backend/api/src/app.ts | 2 + backend/api/src/get-user-journeys.ts | 34 ++++++++++++ common/src/api/schema.ts | 14 +++++ common/src/envs/constants.ts | 1 + web/pages/admin/journeys.tsx | 79 +++++++++++----------------- 6 files changed, 84 insertions(+), 48 deletions(-) create mode 100644 backend/api/src/get-user-journeys.ts diff --git a/backend/api/package.json b/backend/api/package.json index 802731a5..c3ef5855 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -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", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index cbdd9420..15db81dc 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.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} = { '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, diff --git a/backend/api/src/get-user-journeys.ts b/backend/api/src/get-user-journeys.ts new file mode 100644 index 00000000..e0f9e7e5 --- /dev/null +++ b/backend/api/src/get-user-journeys.ts @@ -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, + } +} diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 7a407ed5..cabb89d0 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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 diff --git a/common/src/envs/constants.ts b/common/src/envs/constants.ts index 481cbc1a..e265edd6 100644 --- a/common/src/envs/constants.ts +++ b/common/src/envs/constants.ts @@ -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) } diff --git a/web/pages/admin/journeys.tsx b/web/pages/admin/journeys.tsx index d3b9c038..426fb7b3 100644 --- a/web/pages/admin/journeys.tsx +++ b/web/pages/admin/journeys.tsx @@ -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[]>>({}) const [hoursFromNowQ, setHoursFromNowQ] = usePersistentQueryState('h', '5') - const hoursFromNow = parseInt(hoursFromNowQ ?? '5') - const [unBannedUsers, setUnBannedUsers] = useState([]) - const [bannedUsers, setBannedUsers] = useState([]) - 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 ( @@ -68,8 +43,8 @@ export default function Journeys() {
User Journeys
- 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.