diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts index 41aa9fe9..c4465703 100644 --- a/functions/src/test-scheduled-function.ts +++ b/functions/src/test-scheduled-function.ts @@ -1,6 +1,6 @@ import { APIError, newEndpoint } from './api' -import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails' import { isProd } from './utils' +import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails' // Function for testing scheduled functions locally export const testscheduledfunction = newEndpoint( @@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint( throw new APIError(400, 'This function is only available in dev mode') // Replace your function here - await sendPortfolioUpdateEmailsToAllUsers() + await sendTrendingMarketsEmailsToAllUsers() return { success: true } } diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 68063f95..5d338a79 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -2,24 +2,18 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { - getAllPrivateUsers, - getPrivateUser, - getUser, - getValues, - isProd, - log, -} from './utils' -import { sendInterestingMarketsEmail } from './emails' +import { getGroup, getPrivateUser, getUser, getValues, log } from './utils' import { createRNG, shuffle } from '../../common/util/random' -import { DAY_MS } from '../../common/util/time' +import { DAY_MS, HOUR_MS } from '../../common/util/time' import { filterDefined } from '../../common/util/array' +import { Follow } from '../../common/follow' +import { countBy, uniqBy } from 'lodash' +import { sendInterestingMarketsEmail } from './emails' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) - // TODO change back to Monday after the rest of the emails go out - // every minute on Tuesday for 2 hours starting at 12pm PT (UTC -07:00) - .pubsub.schedule('* 19-20 * * 2') + // every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00) + .pubsub.schedule('* 19-20 * * 1') .timeZone('Etc/UTC') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() @@ -41,20 +35,30 @@ export async function getTrendingContracts() { ) } -async function sendTrendingMarketsEmailsToAllUsers() { +export async function sendTrendingMarketsEmailsToAllUsers() { const numContractsToSend = 6 - const privateUsers = isProd() - ? await getAllPrivateUsers() - : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) - // get all users that haven't unsubscribed from weekly emails - const privateUsersToSendEmailsTo = privateUsers - .filter((user) => { - return ( - user.notificationPreferences.trending_markets.includes('email') && - !user.weeklyTrendingEmailSent - ) - }) - .slice(125) // Send the emails out in batches + // const privateUsers = + // isProd() + // ? await getAllPrivateUsers() + // filterDefined([ + // await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian + // ]) + const privateUsersToSendEmailsTo = + // get all users that haven't unsubscribed from weekly emails + // isProd() + // ? privateUsers + // .filter((user) => { + // user.notificationPreferences.trending_markets.includes('email') && + // !user.weeklyTrendingEmailSent + // }) + // .slice(125) // Send the emails out in batches + // : + // privateUsers + filterDefined([ + await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian + await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), + ]) + log( 'Sending weekly trending emails to', privateUsersToSendEmailsTo.length, @@ -72,41 +76,254 @@ async function sendTrendingMarketsEmailsToAllUsers() { !contract.groupSlugs?.includes('manifold-6748e065087e') ) .slice(0, 20) - log( - `Found ${trendingContracts.length} trending contracts:\n`, - trendingContracts.map((c) => c.question).join('\n ') - ) + // log( + // `Found ${trendingContracts.length} trending contracts:\n`, + // trendingContracts.map((c) => c.question).join('\n ') + // ) - // TODO: convert to Promise.all - for (const privateUser of privateUsersToSendEmailsTo) { - if (!privateUser.email) { - log(`No email for ${privateUser.username}`) - continue - } - const contractsAvailableToSend = trendingContracts.filter((contract) => { - return !contract.uniqueBettorIds?.includes(privateUser.id) - }) - if (contractsAvailableToSend.length < numContractsToSend) { - log('not enough new, unbet-on contracts to send to user', privateUser.id) - await firestore.collection('private-users').doc(privateUser.id).update({ + await Promise.all( + privateUsersToSendEmailsTo.map(async (privateUser) => { + if (!privateUser.email) { + log(`No email for ${privateUser.username}`) + return + } + const marketsAvailableToSend = uniqBy( + [ + ...(await getUserUnBetOnFollowsMarkets( + privateUser.id, + privateUser.id + )), + ...(await getUserUnBetOnGroupsMarkets(privateUser.id)), + ...(await getSimilarBettorsMarkets(privateUser.id)), + ], + (contract) => contract.id + ) + // at least send them trending contracts if nothing else + if (marketsAvailableToSend.length < numContractsToSend) + marketsAvailableToSend.push( + ...trendingContracts + .filter( + (contract) => + !contract.uniqueBettorIds?.includes(privateUser.id) && + !marketsAvailableToSend.map((c) => c.id).includes(contract.id) + ) + .slice(0, numContractsToSend - marketsAvailableToSend.length) + ) + + if (marketsAvailableToSend.length < numContractsToSend) { + log( + 'not enough new, unbet-on contracts to send to user', + privateUser.id + ) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyTrendingEmailSent: true, + }) + return + } + // choose random subset of contracts to send to user + const contractsToSend = chooseRandomSubset( + marketsAvailableToSend, + numContractsToSend + ) + + const user = await getUser(privateUser.id) + if (!user) return + + console.log( + 'sending contracts:', + contractsToSend.map((c) => [c.question, c.popularityScore]) + ) + // if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them + // await sendInterestingMarketsEmail(user, privateUser, contractsToSend) + await sendInterestingMarketsEmail( + user, + privateUsersToSendEmailsTo[0], + contractsToSend + ) + await firestore.collection('private-users').doc(user.id).update({ weeklyTrendingEmailSent: true, }) - continue - } - // choose random subset of contracts to send to user - const contractsToSend = chooseRandomSubset( - contractsAvailableToSend, - numContractsToSend - ) - - const user = await getUser(privateUser.id) - if (!user) continue - - await sendInterestingMarketsEmail(user, privateUser, contractsToSend) - await firestore.collection('private-users').doc(user.id).update({ - weeklyTrendingEmailSent: true, }) - } + ) +} + +// TODO: figure out a good minimum popularity score to filter by +const MINIMUM_POPULARITY_SCORE = 2 + +const getUserUnBetOnFollowsMarkets = async ( + userId: string, + unBetOnByUserId: string +) => { + const follows = await getValues( + firestore.collection('users').doc(userId).collection('follows') + ) + console.log( + 'follows', + follows.map((f) => f.userId) + ) + + const unBetOnContractsFromFollows = await Promise.all( + follows.map(async (follow) => { + const unresolvedContracts = await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('visibility', '==', 'public') + .where('creatorId', '==', follow.userId) + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately + .orderBy('popularityScore', 'desc') + .limit(50) + ) + // filter out contracts that have close times less than 6 hours from now + const openContracts = unresolvedContracts.filter( + (contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS + ) + + return openContracts.filter( + (contract) => !contract.uniqueBettorIds?.includes(unBetOnByUserId) + ) + }) + ) + + const sortedMarkets = unBetOnContractsFromFollows + .flat() + .filter( + (contract) => + contract.popularityScore !== undefined && + contract.popularityScore > MINIMUM_POPULARITY_SCORE + ) + .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) + console.log( + 'sorted top 10 follow Markets', + sortedMarkets + .slice(0, 10) + .map((c) => [c.question, c.popularityScore, c.creatorId]) + ) + return sortedMarkets +} + +const getUserUnBetOnGroupsMarkets = async (userId: string) => { + const snap = await firestore + .collectionGroup('groupMembers') + .where('userId', '==', userId) + .get() + + const groupIds = filterDefined( + snap.docs.map((doc) => doc.ref.parent.parent?.id) + ) + const groups = filterDefined( + await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId))) + ) + console.log( + 'groups', + groups.map((g) => g.name) + ) + const unBetOnContractsFromGroups = await Promise.all( + groups.map(async (group) => { + const unresolvedContracts = await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('visibility', '==', 'public') + .where('groupSlugs', 'array-contains', group.slug) + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately + .orderBy('popularityScore', 'desc') + .limit(50) + ) + // filter out contracts that have close times less than 6 hours from now + const openContracts = unresolvedContracts.filter( + (contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS + ) + + return openContracts.filter( + (contract) => !contract.uniqueBettorIds?.includes(userId) + ) + }) + ) + const sortedMarkets = unBetOnContractsFromGroups + .flat() + .filter( + (contract) => + contract.popularityScore !== undefined && + contract.popularityScore > MINIMUM_POPULARITY_SCORE + ) + .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) + console.log( + 'top 10 sorted group Markets', + sortedMarkets + .slice(0, 10) + .map((c) => [c.question, c.popularityScore, c.groupSlugs]) + ) + return sortedMarkets +} + +// Gets markets followed by similar bettors and bet on by similar bettors +const getSimilarBettorsMarkets = async (userId: string) => { + // get contracts with unique bettor ids with this user + const contractsUserHasBetOn = await getValues( + firestore + .collection('contracts') + .where('uniqueBettorIds', 'array-contains', userId) + ) + // count the number of times each unique bettor id appears on those contracts + const bettorIdsToCounts = countBy( + contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(), + (bettorId) => bettorId + ) + console.log('bettorIdCounts', bettorIdsToCounts) + + // sort by number of times they appear with at least 2 appearances + const sortedBettorIds = Object.entries(bettorIdsToCounts) + .sort((a, b) => b[1] - a[1]) + .filter((bettorId) => bettorId[1] > 2) + .map((entry) => entry[0]) + .filter((bettorId) => bettorId !== userId) + + // get the top 10 most similar bettors (excluding this user) + const similarBettorIds = sortedBettorIds.slice(0, 10) + console.log('sortedBettorIds', sortedBettorIds) + // get their followed users' markets + const followedUsersMarkets = ( + await Promise.all( + similarBettorIds.map(async (bettorId) => + getUserUnBetOnFollowsMarkets(bettorId, userId) + ) + ) + ).flat() + console.log( + 'top 10 followedUsersMarkets', + followedUsersMarkets.map((c) => [c.question, c.creatorId]).slice(0, 10) + ) + + // get contracts with unique bettor ids with this user + const contractsSimilarBettorsHaveBetOn = ( + await getValues( + firestore + .collection('contracts') + .where( + 'uniqueBettorIds', + 'array-contains-any', + sortedBettorIds.slice(0, 10) + ) + .orderBy('popularityScore', 'desc') + .limit(100) + ) + ).filter((contract) => !contract.uniqueBettorIds?.includes(userId)) + console.log( + 'top 10 contractsSimilarBettorsHaveBetOn', + contractsSimilarBettorsHaveBetOn + .map((c) => [ + c.question, + c.uniqueBettorIds?.filter((bid) => similarBettorIds.includes(bid)), + ]) + .slice(0, 10) + ) + + return [...followedUsersMarkets, ...contractsSimilarBettorsHaveBetOn].sort( + (a, b) => (b?.popularityScore ?? 0) - (a?.popularityScore ?? 0) + ) } const fiveMinutes = 5 * 60 * 1000