diff --git a/firestore.rules b/firestore.rules index 48214e3b..0e6bcc45 100644 --- a/firestore.rules +++ b/firestore.rules @@ -34,6 +34,10 @@ service cloud.firestore { allow create: if userId == request.auth.uid; } + match /private-users/{userId}/cache/feed { + allow read: if userId == request.auth.uid || isAdmin(); + } + match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() diff --git a/functions/src/index.ts b/functions/src/index.ts index a3f81b36..2c2281c0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,8 +21,8 @@ export * from './unsubscribe' export * from './update-contract-metrics' export * from './update-user-metrics' export * from './update-recommendations' +export * from './update-user-feed' export * from './backup-db' export * from './change-user-info' export * from './market-close-emails' export * from './add-liquidity' -export * from './get-feed' diff --git a/functions/src/on-view.ts b/functions/src/on-view.ts index 54fe7332..d2f746d5 100644 --- a/functions/src/on-view.ts +++ b/functions/src/on-view.ts @@ -12,13 +12,13 @@ export const onView = functions.firestore const { contractId, timestamp } = snapshot.data() as View await firestore - .doc(`private-users/${userId}/cached/viewCounts`) + .doc(`private-users/${userId}/cache/viewCounts`) .set( { [contractId]: admin.firestore.FieldValue.increment(1) }, { merge: true } ) await firestore - .doc(`private-users/${userId}/cached/lastViewTime`) + .doc(`private-users/${userId}/cache/lastViewTime`) .set({ [contractId]: timestamp }, { merge: true }) }) diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts index be5386df..95868583 100644 --- a/functions/src/update-recommendations.ts +++ b/functions/src/update-recommendations.ts @@ -39,7 +39,7 @@ export const updateUserRecommendations = async ( ), getValue<{ [contractId: string]: number }>( - firestore.doc(`private-users/${user.id}/cached/viewCounts`) + firestore.doc(`private-users/${user.id}/cache/viewCounts`) ), getValues( @@ -53,7 +53,7 @@ export const updateUserRecommendations = async ( const contractScores = getContractScores(contracts, wordScores) const cachedCollection = firestore.collection( - `private-users/${user.id}/cached` + `private-users/${user.id}/cache` ) await cachedCollection.doc('wordScores').set(wordScores) await cachedCollection.doc('contractScores').set(contractScores) diff --git a/functions/src/get-feed.ts b/functions/src/update-user-feed.ts similarity index 67% rename from functions/src/get-feed.ts rename to functions/src/update-user-feed.ts index b241a902..cfb0510a 100644 --- a/functions/src/get-feed.ts +++ b/functions/src/update-user-feed.ts @@ -13,19 +13,18 @@ import { } from '../../common/calculate' import { Bet } from '../../common/bet' import { Comment } from '../../common/comment' +import { User } from '../../common/user' +import { batchedWaitAll } from '../../common/util/promise' const firestore = admin.firestore() const MAX_FEED_CONTRACTS = 60 -export const getFeed = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (_data, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } - +export const updateUserFeed = functions.pubsub + .schedule('every 60 minutes') + .onRun(async () => { // Get contracts bet on or created in last week. - const contractsPromise = Promise.all([ + const contracts = await Promise.all([ getValues( firestore .collection('contracts') @@ -46,59 +45,59 @@ export const getFeed = functions return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now()) }) - const userCacheCollection = firestore.collection( - `private-users/${userId}/cached` - ) - const [recommendationScores, lastViewedTime] = await Promise.all([ - getValue<{ [contractId: string]: number }>( - userCacheCollection.doc('contractScores') - ), - getValue<{ [contractId: string]: number }>( - userCacheCollection.doc('lastViewTime') - ), - ]).then((dicts) => dicts.map((dict) => dict ?? {})) + const users = await getValues(firestore.collection('users')) - const contracts = await contractsPromise - - const averageRecScore = - 1 + - _.sumBy( - contracts.filter((c) => recommendationScores[c.id] !== undefined), - (c) => recommendationScores[c.id] - ) / - (contracts.length + 1) - - console.log({ recommendationScores, averageRecScore, lastViewedTime }) - - const scoredContracts = contracts.map((contract) => { - const score = scoreContract( - contract, - recommendationScores[contract.id] ?? averageRecScore, - lastViewedTime[contract.id] - ) - return [contract, score] as [Contract, number] - }) - - const sortedContracts = _.sortBy( - scoredContracts, - ([_, score]) => score - ).reverse() - - console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) - - const feedContracts = sortedContracts - .slice(0, MAX_FEED_CONTRACTS) - .map(([c]) => c) - - const feed = await Promise.all( - feedContracts.map((contract) => getRecentBetsAndComments(contract)) - ) - - console.log('feed', feed) - - return { status: 'success', feed } + await batchedWaitAll(users.map((user) => () => updateFeed(user, contracts))) }) +const updateFeed = async (user: User, contracts: Contract[]) => { + const userCacheCollection = firestore.collection( + `private-users/${user.id}/cache` + ) + const [recommendationScores, lastViewedTime] = await Promise.all([ + getValue<{ [contractId: string]: number }>( + userCacheCollection.doc('contractScores') + ), + getValue<{ [contractId: string]: number }>( + userCacheCollection.doc('lastViewTime') + ), + ]).then((dicts) => dicts.map((dict) => dict ?? {})) + + const averageRecScore = + 1 + + _.sumBy( + contracts.filter((c) => recommendationScores[c.id] !== undefined), + (c) => recommendationScores[c.id] + ) / + (contracts.length + 1) + + const scoredContracts = contracts.map((contract) => { + const score = scoreContract( + contract, + recommendationScores[contract.id] ?? averageRecScore, + lastViewedTime[contract.id] + ) + return [contract, score] as [Contract, number] + }) + + const sortedContracts = _.sortBy( + scoredContracts, + ([_, score]) => score + ).reverse() + + console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) + + const feedContracts = sortedContracts + .slice(0, MAX_FEED_CONTRACTS) + .map(([c]) => c) + + const feed = await Promise.all( + feedContracts.map((contract) => getRecentBetsAndComments(contract)) + ) + + await userCacheCollection.doc('feed').set(feed) +} + function scoreContract( contract: Contract, recommendationScore: number, @@ -171,12 +170,16 @@ async function getRecentBetsAndComments(contract: Contract) { contractDoc .collection('bets') .where('createdTime', '>', Date.now() - DAY_MS) + .orderBy('createdTime', 'desc') + .limit(1) ), getValues( contractDoc .collection('comments') .where('createdTime', '>', Date.now() - 3 * DAY_MS) + .orderBy('createdTime', 'desc') + .limit(3) ), ]) diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index 2ceb38e3..0a14779d 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -5,9 +5,10 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { useTimeSinceFirstRender } from './use-time-since-first-render' import { trackLatency } from '../lib/firebase/tracking' -import { getFeed } from '../lib/firebase/api-call' +import { User } from '../../common/user' +import { getUserFeed } from '../lib/firebase/users' -export const useAlgoFeed = () => { +export const useAlgoFeed = (user: User | null | undefined) => { const [feed, setFeed] = useState< { contract: Contract @@ -19,14 +20,15 @@ export const useAlgoFeed = () => { const getTime = useTimeSinceFirstRender() useEffect(() => { - getFeed().then(({ data }) => { - console.log('got data', data) - setFeed(data.feed) + if (user) { + getUserFeed(user.id).then((feed) => { + setFeed(feed) - trackLatency('feed', getTime()) - console.log('feed load time', getTime()) - }) - }, [getTime]) + trackLatency('feed', getTime()) + console.log('feed load time', getTime()) + }) + } + }, [user, getTime]) return feed } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index f4c44cb6..1c5522e7 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,7 +1,4 @@ import { httpsCallable } from 'firebase/functions' -import { Bet } from '../../../common/bet' -import { Comment } from '../../../common/comment' -import { Contract } from '../../../common/contract' import { Fold } from '../../../common/fold' import { User } from '../../../common/user' import { randomString } from '../../../common/util/random' @@ -74,15 +71,3 @@ export const addLiquidity = (data: { amount: number; contractId: string }) => { .then((r) => r.data as { status: string }) .catch((e) => ({ status: 'error', message: e.message })) } - -export const getFeed = cloudFunction< - undefined, - { - status: 'error' | 'success' - feed: { - contract: Contract - recentBets: Bet[] - recentComments: Comment[] - }[] - } ->('getFeed') diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index e7805626..e07d138c 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -23,8 +23,11 @@ import _ from 'lodash' import { app } from './init' import { PrivateUser, User } from '../../../common/user' import { createUser } from './api-call' -import { getValues, listenForValue, listenForValues } from './utils' +import { getValue, getValues, listenForValue, listenForValues } from './utils' import { DAY_MS } from '../../../common/util/time' +import { Contract } from './contracts' +import { Bet } from './bets' +import { Comment } from './comments' export type { User } @@ -207,3 +210,15 @@ export async function getDailyNewUsers( return usersByDay } + +export async function getUserFeed(userId: string) { + const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed') + const userFeed = await getValue<{ + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] + }>(feedDoc) + return userFeed?.feed ?? [] +} diff --git a/web/pages/home.tsx b/web/pages/home.tsx index ce54a835..6f1ec93c 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -15,7 +15,7 @@ import { ContractPageContent } from './[username]/[contractSlug]' const Home = () => { const user = useUser() - const feed = useAlgoFeed() + const feed = useAlgoFeed(user) const router = useRouter() const { u: username, s: slug } = router.query