diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index b27ac977..7c2153c1 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -21,6 +21,25 @@ const computeInvestmentValue = ( }) } +export const computeInvestmentValueCustomProb = ( + bets: Bet[], + contract: Contract, + p: number +) => { + return sumBy(bets, (bet) => { + if (!contract || contract.isResolved) return 0 + if (bet.sale || bet.isSold) return 0 + const { outcome, shares } = bet + + const betP = outcome === 'YES' ? p : 1 - p + + const payout = betP * shares + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const periodFilteredContracts = userContracts.filter( (contract) => contract.createdTime >= startTime diff --git a/common/user.ts b/common/user.ts index 0372d99b..b1365929 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { email?: string weeklyTrendingEmailSent?: boolean + weeklyPortfolioUpdateEmailSent?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/functions/src/email-templates/weekly-portfolio-update-no-movers.html b/functions/src/email-templates/weekly-portfolio-update-no-movers.html new file mode 100644 index 00000000..15303992 --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update-no-movers.html @@ -0,0 +1,411 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html new file mode 100644 index 00000000..fd99837f --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -0,0 +1,510 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+

+ + And here's some of the biggest changes in your portfolio: + +

+
+
+ + + + + + + + + + + + + + + + + + +
+ + {{question1Title}} + + + +

+ {{question1Prob}} + +

+ {{question1Change}} + +

+

+
+ + {{question2Title}} + + + +

+ {{question2Prob}} + +

+ {{question2Change}} + +

+

+
+ + {{question3Title}} + + + +

+ {{question3Prob}} + +

+ {{question3Change}} + +

+

+
+ + {{question4Title}} + + + +

+ {{question4Prob}} + +

+ {{question4Change}} + +

+

+
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 98309ebe..dd91789a 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getUser } from './utils' +import { contractUrl, getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' +import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { - getNotificationDestinationsForUser, - notification_preference, -} from '../../common/user-notification-preferences' + PerContractInvestmentsData, + OverallPerformanceData, +} from 'functions/src/weekly-portfolio-emails' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -152,9 +153,10 @@ export const sendWelcomeEmail = async ( const { name } = user const firstName = name.split(' ')[0] - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) return await sendTemplateEmail( privateUser.email, @@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async ( const { name } = user const firstName = name.split(' ')[0] - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) + return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', @@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async ( const { name } = user const firstName = name.split(' ')[0] - - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -286,10 +290,10 @@ export const sendThankYouEmail = async ( const { name } = user const firstName = name.split(' ')[0] - - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'thank_you_for_purchases' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'thank_you_for_purchases' + ) return await sendTemplateEmail( privateUser.email, @@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async ( ) return - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'trending_markets' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'trending_markets' + ) const { name } = user const firstName = name.split(' ')[0] @@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async ( ) } -function contractUrl(contract: Contract) { - return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` -} - function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } @@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async ( } ) } + +export const sendWeeklyPortfolioUpdateEmail = async ( + user: User, + privateUser: PrivateUser, + investments: PerContractInvestmentsData[], + overallPerformance: OverallPerformanceData +) => { + if ( + !privateUser || + !privateUser.email || + !privateUser.notificationPreferences.profit_loss_updates.includes('email') + ) + return + + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'profit_loss_updates' + ) + + const { name } = user + const firstName = name.split(' ')[0] + const templateData: Record = { + name: firstName, + unsubscribeUrl, + ...overallPerformance, + } + investments.forEach((investment, i) => { + templateData[`question${i + 1}Title`] = investment.questionTitle + templateData[`question${i + 1}Url`] = investment.questionUrl + templateData[`question${i + 1}Prob`] = investment.questionProb + templateData[`question${i + 1}Change`] = investment.questionChange + templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle + }) + + await sendTemplateEmail( + privateUser.email, + // 'iansphilips@gmail.com', + `Here's your weekly portfolio update!`, + investments.length === 0 + ? 'portfolio-update-no-movers' + : 'portfolio-update', + templateData + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 2cb6f515..4844cea8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -30,6 +30,7 @@ export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' export * from './on-update-contract-follow' export * from './on-update-like' +export * from './weekly-portfolio-emails' // v2 export * from './health' diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flag.ts index 947e3e88..9a769498 100644 --- a/functions/src/reset-weekly-emails-flag.ts +++ b/functions/src/reset-weekly-emails-flag.ts @@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions privateUsers.map(async (user) => { return firestore.collection('private-users').doc(user.id).update({ weeklyTrendingEmailSent: false, + weeklyPortfolioEmailSent: false, }) }) ) diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 6d062d40..99ac6281 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -28,6 +28,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' +import { testscheduledfunction } from './test-scheduled-function' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -69,6 +70,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) +addEndpointRoute('/testscheduledfunction', testscheduledfunction) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts new file mode 100644 index 00000000..41aa9fe9 --- /dev/null +++ b/functions/src/test-scheduled-function.ts @@ -0,0 +1,17 @@ +import { APIError, newEndpoint } from './api' +import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails' +import { isProd } from './utils' + +// Function for testing scheduled functions locally +export const testscheduledfunction = newEndpoint( + { method: 'GET', memory: '4GiB' }, + async (_req) => { + if (isProd()) + throw new APIError(400, 'This function is only available in dev mode') + + // Replace your function here + await sendPortfolioUpdateEmailsToAllUsers() + + return { success: true } + } +) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 6bb8349a..efc22e53 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -170,3 +170,7 @@ export const chargeUser = ( export const getContractPath = (contract: Contract) => { return `/${contract.creatorUsername}/${contract.slug}` } + +export function contractUrl(contract: Contract) { + return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` +} diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts new file mode 100644 index 00000000..dcbb68dd --- /dev/null +++ b/functions/src/weekly-portfolio-emails.ts @@ -0,0 +1,280 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract, CPMMContract } from '../../common/contract' +import { + getAllPrivateUsers, + getPrivateUser, + getUser, + getValue, + getValues, + isProd, + log, +} from './utils' +import { filterDefined } from '../../common/util/array' +import { DAY_MS } from '../../common/util/time' +import { partition, sortBy, sum, uniq } from 'lodash' +import { Bet } from '../../common/bet' +import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics' +import { sendWeeklyPortfolioUpdateEmail } from './emails' +import { contractUrl } from './utils' +import { Txn } from '../../common/txn' +import { formatMoney } from '../../common/util/format' + +// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week +export const weeklyPortfolioUpdateEmails = functions + .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) + // every minute on Friday for an hour at 12pm PT (UTC -07:00) + .pubsub.schedule('* 19 * * 5') + .timeZone('Etc/UTC') + .onRun(async () => { + await sendPortfolioUpdateEmailsToAllUsers() + }) + +const firestore = admin.firestore() + +export async function sendPortfolioUpdateEmailsToAllUsers() { + const privateUsers = isProd() + ? // ian & stephen's ids + // ? filterDefined([ + // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), + // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), + // ]) + await getAllPrivateUsers() + : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) + // get all users that haven't unsubscribed from weekly emails + const privateUsersToSendEmailsTo = privateUsers + .filter((user) => { + return isProd() + ? user.notificationPreferences.profit_loss_updates.includes('email') && + !user.weeklyPortfolioUpdateEmailSent + : true + }) + // Send emails in batches + .slice(0, 200) + log( + 'Sending weekly portfolio emails to', + privateUsersToSendEmailsTo.length, + 'users' + ) + + const usersBets: { [userId: string]: Bet[] } = {} + // get all bets made by each user + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return getValues( + firestore.collectionGroup('bets').where('userId', '==', user.id) + ).then((bets) => { + usersBets[user.id] = bets + }) + }) + ) + + const usersToContractsCreated: { [userId: string]: Contract[] } = {} + // Get all contracts created by each user + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return getValues( + firestore + .collection('contracts') + .where('creatorId', '==', user.id) + .where('createdTime', '>', Date.now() - 7 * DAY_MS) + ).then((contracts) => { + usersToContractsCreated[user.id] = contracts + }) + }) + ) + + // Get all txns the users received over the past week + const usersToTxnsReceived: { [userId: string]: Txn[] } = {} + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return getValues( + firestore + .collection(`txns`) + .where('toId', '==', user.id) + .where('createdTime', '>', Date.now() - 7 * DAY_MS) + ).then((txn) => { + usersToTxnsReceived[user.id] = txn + }) + }) + ) + + // Get a flat map of all the bets that users made to get the contracts they bet on + const contractsUsersBetOn = filterDefined( + await Promise.all( + uniq( + Object.values(usersBets).flatMap((bets) => + bets.map((bet) => bet.contractId) + ) + ).map((contractId) => + getValue(firestore.collection('contracts').doc(contractId)) + ) + ) + ) + log('Found', contractsUsersBetOn.length, 'contracts') + let count = 0 + await Promise.all( + privateUsersToSendEmailsTo.map(async (privateUser) => { + const user = await getUser(privateUser.id) + if (!user) return + const userBets = usersBets[privateUser.id] as Bet[] + const contractsUserBetOn = contractsUsersBetOn.filter((contract) => + userBets.some((bet) => bet.contractId === contract.id) + ) + const contractsBetOnInLastWeek = uniq( + userBets + .filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS) + .map((bet) => bet.contractId) + ) + const totalTips = sum( + usersToTxnsReceived[privateUser.id] + .filter((txn) => txn.category === 'TIP') + .map((txn) => txn.amount) + ) + const greenBg = 'rgba(0,160,0,0.2)' + const redBg = 'rgba(160,0,0,0.2)' + const clearBg = 'rgba(255,255,255,0)' + const roundedProfit = + Math.round(user.profitCached.weekly) === 0 + ? 0 + : Math.floor(user.profitCached.weekly) + const performanceData = { + profit: formatMoney(user.profitCached.weekly), + profit_style: `background-color: ${ + roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg + }`, + markets_created: + usersToContractsCreated[privateUser.id].length.toString(), + tips_received: formatMoney(totalTips), + unique_bettors: usersToTxnsReceived[privateUser.id] + .filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS') + .length.toString(), + markets_traded: contractsBetOnInLastWeek.length.toString(), + prediction_streak: + (user.currentBettingStreak?.toString() ?? '0') + ' days', + // More options: bonuses, tips given, + } as OverallPerformanceData + + const investmentValueDifferences = sortBy( + filterDefined( + contractsUserBetOn.map((contract) => { + const cpmmContract = contract as CPMMContract + if (cpmmContract === undefined || cpmmContract.prob === undefined) + return + const bets = userBets.filter( + (bet) => bet.contractId === contract.id + ) + + const marketProbabilityAWeekAgo = + cpmmContract.prob - cpmmContract.probChanges.week + const currentMarketProbability = cpmmContract.resolutionProbability + ? cpmmContract.resolutionProbability + : cpmmContract.prob + const betsValueAWeekAgo = computeInvestmentValueCustomProb( + bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS), + contract, + marketProbabilityAWeekAgo + ) + const currentBetsValue = computeInvestmentValueCustomProb( + bets, + contract, + currentMarketProbability + ) + const marketChange = + currentMarketProbability - marketProbabilityAWeekAgo + return { + currentValue: currentBetsValue, + pastValue: betsValueAWeekAgo, + difference: currentBetsValue - betsValueAWeekAgo, + contractSlug: contract.slug, + marketProbAWeekAgo: marketProbabilityAWeekAgo, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionProb: cpmmContract.resolution + ? cpmmContract.resolution + : Math.round(cpmmContract.prob * 100) + '%', + questionChange: + (marketChange > 0 ? '+' : '') + + Math.round(marketChange * 100) + + '%', + questionChangeStyle: `color: ${ + currentMarketProbability > marketProbabilityAWeekAgo + ? 'rgba(0,160,0,1)' + : '#a80000' + };`, + } as PerContractInvestmentsData + }) + ), + (differences) => Math.abs(differences.difference) + ).reverse() + + log( + 'Found', + investmentValueDifferences.length, + 'investment differences for user', + privateUser.id + ) + + const [winningInvestments, losingInvestments] = partition( + investmentValueDifferences.filter( + (diff) => + diff.pastValue > 0.01 && + Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1% + ), + (investmentsData: PerContractInvestmentsData) => { + return investmentsData.difference > 0 + } + ) + // pick 3 winning investments and 3 losing investments + const topInvestments = winningInvestments.slice(0, 2) + const worstInvestments = losingInvestments.slice(0, 2) + // if no bets in the last week ANd no market movers AND no markets created, don't send email + if ( + contractsBetOnInLastWeek.length === 0 && + topInvestments.length === 0 && + worstInvestments.length === 0 && + usersToContractsCreated[privateUser.id].length === 0 + ) { + log('No bets in last week, no market movers, no markets created') + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyPortfolioUpdateEmailSent: true, + }) + return + } + await sendWeeklyPortfolioUpdateEmail( + user, + privateUser, + topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], + performanceData + ) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyPortfolioUpdateEmailSent: true, + }) + log('Sent weekly portfolio update email to', privateUser.email) + count++ + log('sent out emails to user count:', count) + }) + ) +} + +export type PerContractInvestmentsData = { + questionTitle: string + questionUrl: string + questionProb: string + questionChange: string + questionChangeStyle: string + currentValue: number + pastValue: number + difference: number +} + +export type OverallPerformanceData = { + profit: string + prediction_streak: string + markets_traded: string + profit_style: string + tips_received: string + markets_created: string + unique_bettors: string +}