Personalized interesting markets emails [WIP] (#1001)

* Test new personalized emails in prod - logs only

* fix import
This commit is contained in:
Ian Philips 2022-10-04 16:47:06 -06:00 committed by GitHub
parent c115b5cca7
commit 935ff7b97a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 276 additions and 59 deletions

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api' import { APIError, newEndpoint } from './api'
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
import { isProd } from './utils' import { isProd } from './utils'
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails'
// Function for testing scheduled functions locally // Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint( export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode') throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here // Replace your function here
await sendPortfolioUpdateEmailsToAllUsers() await sendTrendingMarketsEmailsToAllUsers()
return { success: true } return { success: true }
} }

View File

@ -2,24 +2,18 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import { getGroup, getPrivateUser, getUser, getValues, log } from './utils'
getAllPrivateUsers,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random' 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 { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow'
import { countBy, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// TODO change back to Monday after the rest of the emails go out // every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
// every minute on Tuesday for 2 hours starting at 12pm PT (UTC -07:00) .pubsub.schedule('* 19-20 * * 1')
.pubsub.schedule('* 19-20 * * 2')
.timeZone('Etc/UTC') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers() await sendTrendingMarketsEmailsToAllUsers()
@ -41,20 +35,30 @@ export async function getTrendingContracts() {
) )
} }
async function sendTrendingMarketsEmailsToAllUsers() { export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
const privateUsers = isProd() // const privateUsers =
? await getAllPrivateUsers() // isProd()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // ? await getAllPrivateUsers()
// filterDefined([
// await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
const privateUsersToSendEmailsTo =
// get all users that haven't unsubscribed from weekly emails // get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers // isProd()
.filter((user) => { // ? privateUsers
return ( // .filter((user) => {
user.notificationPreferences.trending_markets.includes('email') && // user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent // !user.weeklyTrendingEmailSent
) // })
}) // .slice(125) // Send the emails out in batches
.slice(125) // Send the emails out in batches // :
// privateUsers
filterDefined([
await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'),
])
log( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length, privateUsersToSendEmailsTo.length,
@ -72,41 +76,254 @@ async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-6748e065087e') !contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 20) .slice(0, 20)
log( // log(
`Found ${trendingContracts.length} trending contracts:\n`, // `Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ') // trendingContracts.map((c) => c.question).join('\n ')
) // )
// TODO: convert to Promise.all await Promise.all(
for (const privateUser of privateUsersToSendEmailsTo) { privateUsersToSendEmailsTo.map(async (privateUser) => {
if (!privateUser.email) { if (!privateUser.email) {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
continue return
} }
const contractsAvailableToSend = trendingContracts.filter((contract) => { const marketsAvailableToSend = uniqBy(
return !contract.uniqueBettorIds?.includes(privateUser.id) [
}) ...(await getUserUnBetOnFollowsMarkets(
if (contractsAvailableToSend.length < numContractsToSend) { privateUser.id,
log('not enough new, unbet-on contracts to send to user', 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({ await firestore.collection('private-users').doc(privateUser.id).update({
weeklyTrendingEmailSent: true, weeklyTrendingEmailSent: true,
}) })
continue return
} }
// choose random subset of contracts to send to user // choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset( const contractsToSend = chooseRandomSubset(
contractsAvailableToSend, marketsAvailableToSend,
numContractsToSend numContractsToSend
) )
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) continue if (!user) return
await sendInterestingMarketsEmail(user, privateUser, contractsToSend) 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({ await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true, 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<Follow>(
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<Contract>(
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<Contract>(
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<Contract>(
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<Contract>(
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 const fiveMinutes = 5 * 60 * 1000