From 67d0a6c0c253cdd72cc56d875fd46b5f77756f02 Mon Sep 17 00:00:00 2001 From: Ben Congdon Date: Wed, 22 Jun 2022 09:05:54 -0700 Subject: [PATCH] Create Top Followed Users leaderboard (#531) * Create Top Followed Users leaderboard * Switch to increment/decrement approach for caching user follower counts * Backfill script for user follow counts * Appease ESLint * Address review comment Co-authored-by: James Grugett --- common/follow.ts | 4 ++ common/user.ts | 2 + functions/src/create-user.ts | 1 + functions/src/index.ts | 1 + functions/src/on-follow-user.ts | 12 +++++- functions/src/on-unfollow-user.ts | 18 ++++++++ functions/src/scripts/backfill-followers.ts | 46 +++++++++++++++++++++ web/lib/firebase/users.ts | 11 +++++ web/pages/leaderboards.tsx | 27 ++++++++++-- 9 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 common/follow.ts create mode 100644 functions/src/on-unfollow-user.ts create mode 100644 functions/src/scripts/backfill-followers.ts diff --git a/common/follow.ts b/common/follow.ts new file mode 100644 index 00000000..04ca6899 --- /dev/null +++ b/common/follow.ts @@ -0,0 +1,4 @@ +export type Follow = { + userId: string + timestamp: number +} diff --git a/common/user.ts b/common/user.ts index 3b74ac1a..0553b95c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -18,6 +18,8 @@ export type User = { totalPnLCached: number creatorVolumeCached: number + followerCountCached: number + followedCategories?: string[] } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 9849fce4..2471b92f 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -72,6 +72,7 @@ export const createUser = functions createdTime: Date.now(), totalPnLCached: 0, creatorVolumeCached: 0, + followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, } diff --git a/functions/src/index.ts b/functions/src/index.ts index 20511f7d..501f6b76 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -25,6 +25,7 @@ export * from './on-create-answer' export * from './on-update-contract' export * from './on-create-contract' export * from './on-follow-user' +export * from './on-unfollow-user' export * from './on-create-liquidity-provision' // v2 diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index 07fd0454..ad85f4d3 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -1,12 +1,16 @@ import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + import { getUser } from './utils' import { createNotification } from './create-notification' +import { FieldValue } from 'firebase-admin/firestore' export const onFollowUser = functions.firestore .document('users/{userId}/follows/{followedUserId}') .onCreate(async (change, context) => { - const { userId } = context.params as { + const { userId, followedUserId } = context.params as { userId: string + followedUserId: string } const { eventId } = context @@ -15,6 +19,10 @@ export const onFollowUser = functions.firestore const followingUser = await getUser(userId) if (!followingUser) throw new Error('Could not find following user') + await firestore.doc(`users/${followedUserId}`).update({ + followerCountCached: FieldValue.increment(1), + }) + await createNotification( followingUser.id, 'follow', @@ -27,3 +35,5 @@ export const onFollowUser = functions.firestore follow.userId ) }) + +const firestore = admin.firestore() diff --git a/functions/src/on-unfollow-user.ts b/functions/src/on-unfollow-user.ts new file mode 100644 index 00000000..e9a199d6 --- /dev/null +++ b/functions/src/on-unfollow-user.ts @@ -0,0 +1,18 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { FieldValue } from 'firebase-admin/firestore' + +export const onUnfollowUser = functions.firestore + .document('users/{userId}/follows/{followedUserId}') + .onDelete(async (change, context) => { + const { followedUserId } = context.params as { + followedUserId: string + } + + await firestore.doc(`users/${followedUserId}`).update({ + followerCountCached: FieldValue.increment(-1), + }) + }) + +const firestore = admin.firestore() diff --git a/functions/src/scripts/backfill-followers.ts b/functions/src/scripts/backfill-followers.ts new file mode 100644 index 00000000..845d8637 --- /dev/null +++ b/functions/src/scripts/backfill-followers.ts @@ -0,0 +1,46 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { User } from 'common/user' +import { Follow } from 'common/follow' + +const firestore = admin.firestore() + +async function backfillFollowers() { + console.log('Backfilling user follower counts') + const followerCounts: { [userId: string]: number } = {} + const users = await getValues(firestore.collection('users')) + + console.log(`Loaded ${users.length} users. Calculating follower counts...`) + for (const [idx, user] of users.entries()) { + console.log(`Querying user ${user.id} (${idx + 1}/${users.length})`) + const follows = await getValues( + firestore.collection('users').doc(user.id).collection('follows') + ) + + for (const follow of follows) { + followerCounts[follow.userId] = (followerCounts[follow.userId] || 0) + 1 + } + } + + console.log( + `Finished calculating follower counts. Persisting cached follower counts...` + ) + for (const [idx, user] of users.entries()) { + console.log(`Persisting user ${user.id} (${idx + 1}/${users.length})`) + const followerCount = followerCounts[user.id] || 0 + await firestore + .collection('users') + .doc(user.id) + .update({ followerCountCached: followerCount }) + } +} + +if (require.main === module) { + backfillFollowers() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 344ba7e6..860e1a40 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -195,6 +195,17 @@ const topCreatorsQuery = query( limit(20) ) +export async function getTopFollowed() { + const users = await getValues(topFollowedQuery) + return users.slice(0, 20) +} + +const topFollowedQuery = query( + collection(db, 'users'), + orderBy('followerCountCached', 'desc'), + limit(20) +) + export function getTopCreators() { return getValues(topCreatorsQuery) } diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 6a014834..762640de 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -1,22 +1,29 @@ import { Col } from 'web/components/layout/col' import { Leaderboard } from 'web/components/leaderboard' import { Page } from 'web/components/page' -import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users' +import { + getTopCreators, + getTopTraders, + getTopFollowed, + User, +} from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { useTracking } from 'web/hooks/use-tracking' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { - const [topTraders, topCreators] = await Promise.all([ + const [topTraders, topCreators, topFollowed] = await Promise.all([ getTopTraders().catch(() => {}), getTopCreators().catch(() => {}), + getTopFollowed().catch(() => {}), ]) return { props: { topTraders, topCreators, + topFollowed, }, revalidate: 60, // regenerate after a minute @@ -26,12 +33,14 @@ export async function getStaticPropz() { export default function Leaderboards(props: { topTraders: User[] topCreators: User[] + topFollowed: User[] }) { props = usePropz(props, getStaticPropz) ?? { topTraders: [], topCreators: [], + topFollowed: [], } - const { topTraders, topCreators } = props + const { topTraders, topCreators, topFollowed } = props useTracking('view leaderboards') @@ -59,6 +68,18 @@ export default function Leaderboards(props: { ]} /> + + user.followerCountCached, + }, + ]} + /> + ) }