diff --git a/common/badge.ts b/common/badge.ts new file mode 100644 index 00000000..c20b1f03 --- /dev/null +++ b/common/badge.ts @@ -0,0 +1,123 @@ +import { User } from './user' + +export type Badge = { + type: BadgeTypes + createdTime: number + data: { [key: string]: any } + name: 'Proven Correct' | 'Streaker' | 'Market Creator' +} + +export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR' + +export type ProvenCorrectBadgeData = { + type: 'PROVEN_CORRECT' + data: { + contractSlug: string + contractCreatorUsername: string + contractTitle: string + commentId: string + betAmount: number + } +} + +export type MarketCreatorBadgeData = { + type: 'MARKET_CREATOR' + data: { + totalContractsCreated: number + } +} + +export type StreakerBadgeData = { + type: 'STREAKER' + data: { + totalBettingStreak: number + } +} + +export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData +export type StreakerBadge = Badge & StreakerBadgeData +export type MarketCreatorBadge = Badge & MarketCreatorBadgeData + +export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5 +export const provenCorrectRarityThresholds = [1, 1000, 10000] +const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => { + const { betAmount } = badge.data + const thresholdArray = provenCorrectRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (betAmount >= thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const streakerBadgeRarityThresholds = [1, 50, 250] +const calculateStreakerBadgeRarity = (badge: StreakerBadge) => { + const { totalBettingStreak } = badge.data + const thresholdArray = streakerBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalBettingStreak == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const marketCreatorBadgeRarityThresholds = [1, 75, 300] +const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => { + const { totalContractsCreated } = badge.data + const thresholdArray = marketCreatorBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalContractsCreated == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export type rarities = 'bronze' | 'silver' | 'gold' + +const rarityRanks: { [key: number]: rarities } = { + 1: 'bronze', + 2: 'silver', + 3: 'gold', +} + +export const calculateBadgeRarity = (badge: Badge) => { + switch (badge.type) { + case 'PROVEN_CORRECT': + return rarityRanks[ + calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge) + ] + case 'MARKET_CREATOR': + return rarityRanks[ + calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge) + ] + case 'STREAKER': + return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)] + default: + return rarityRanks[0] + } +} + +export const getBadgesByRarity = (user: User | null | undefined) => { + const rarities: { [key in rarities]: number } = { + bronze: 0, + silver: 0, + gold: 0, + } + if (!user) return rarities + Object.values(user.achievements).map((value) => { + value.badges.map((badge) => { + rarities[calculateBadgeRarity(badge)] = + (rarities[calculateBadgeRarity(badge)] ?? 0) + 1 + }) + }) + return rarities +} diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 9ad44522..47fccd86 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,11 +1,17 @@ -import { last, sortBy, sum, sumBy, uniq } from 'lodash' -import { calculatePayout } from './calculate' +import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash' +import { calculatePayout, getContractBetMetrics } from './calculate' import { Bet, LimitBet } from './bet' -import { Contract, CPMMContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + CPMMContract, + DPMContract, +} from './contract' import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' import { getCpmmProbability } from './calculate-cpmm' +import { removeUndefinedProps } from './util/object' const computeInvestmentValue = ( bets: Bet[], @@ -35,8 +41,7 @@ export const computeInvestmentValueCustomProb = ( const betP = outcome === 'YES' ? p : 1 - p - const payout = betP * shares - const value = payout - (bet.loanAmount ?? 0) + const value = betP * shares if (isNaN(value)) return 0 return value }) @@ -194,14 +199,9 @@ export const calculateNewPortfolioMetrics = ( } const calculateProfitForPeriod = ( - startTime: number, - descendingPortfolio: PortfolioMetrics[], + startingPortfolio: PortfolioMetrics | undefined, currentProfit: number ) => { - const startingPortfolio = descendingPortfolio.find( - (p) => p.timestamp < startTime - ) - if (startingPortfolio === undefined) { return currentProfit } @@ -216,33 +216,88 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { } export const calculateNewProfit = ( - portfolioHistory: PortfolioMetrics[], + portfolioHistory: Record< + 'current' | 'day' | 'week' | 'month', + PortfolioMetrics | undefined + >, newPortfolio: PortfolioMetrics ) => { const allTimeProfit = calculatePortfolioProfit(newPortfolio) - const descendingPortfolio = sortBy( - portfolioHistory, - (p) => p.timestamp - ).reverse() const newProfit = { - daily: calculateProfitForPeriod( - Date.now() - 1 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - weekly: calculateProfitForPeriod( - Date.now() - 7 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - monthly: calculateProfitForPeriod( - Date.now() - 30 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), + daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit), + weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit), + monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit), allTime: allTimeProfit, } return newProfit } + +export const calculateMetricsByContract = ( + bets: Bet[], + contractsById: Dictionary +) => { + const betsByContract = groupBy(bets, (bet) => bet.contractId) + const unresolvedContracts = Object.keys(betsByContract) + .map((cid) => contractsById[cid]) + .filter((c) => c && !c.isResolved) + + return unresolvedContracts.map((c) => { + const bets = betsByContract[c.id] ?? [] + const current = getContractBetMetrics(c, bets) + + let periodMetrics + if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') { + const periods = ['day', 'week', 'month'] as const + periodMetrics = Object.fromEntries( + periods.map((period) => [ + period, + calculatePeriodProfit(c, bets, period), + ]) + ) + } + + return removeUndefinedProps({ + contractId: c.id, + ...current, + from: periodMetrics, + }) + }) +} + +const calculatePeriodProfit = ( + contract: CPMMBinaryContract, + bets: Bet[], + period: 'day' | 'week' | 'month' +) => { + const days = period === 'day' ? 1 : period === 'week' ? 7 : 30 + const fromTime = Date.now() - days * DAY_MS + const previousBets = bets.filter((b) => b.createdTime < fromTime) + + const prevProb = contract.prob - contract.probChanges[period] + const prob = contract.resolutionProbability + ? contract.resolutionProbability + : contract.prob + + const previousBetsValue = computeInvestmentValueCustomProb( + previousBets, + contract, + prevProb + ) + const currentBetsValue = computeInvestmentValueCustomProb( + previousBets, + contract, + prob + ) + const profit = currentBetsValue - previousBetsValue + const profitPercent = + previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue) + + return { + profit, + profitPercent, + prevValue: previousBetsValue, + value: currentBetsValue, + } +} diff --git a/common/calculate.ts b/common/calculate.ts index 44dc9113..47fee8c6 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -215,7 +215,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { } const profit = payout + saleValue + redeemed - totalInvested - const profitPercent = (profit / totalInvested) * 100 + const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100 const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const hasShares = Object.values(totalShares).some( diff --git a/common/new-contract.ts b/common/new-contract.ts index 241f0a0f..6a7f57da 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -63,6 +63,7 @@ export function getNewContract( tags: [], lowercaseTags: [], visibility, + unlistedById: visibility === 'unlisted' ? creator.id : undefined, isResolved: false, createdTime: Date.now(), closeTime, diff --git a/common/notification.ts b/common/notification.ts index b75e3d4a..436393a5 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -4,7 +4,7 @@ export type Notification = { id: string userId: string reasonText?: string - reason?: notification_reason_types + reason?: notification_reason_types | notification_preference createdTime: number viewTime?: number isSeen: boolean @@ -46,6 +46,7 @@ export type notification_source_types = | 'loan' | 'like' | 'tip_and_like' + | 'badge' export type notification_source_update_types = | 'created' @@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { simple: `Only on markets you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`, }, + badges_awarded: { + simple: 'New badges awarded', + detailed: 'New badges you have earned', + }, opt_out_all: { simple: 'Opt out of all notifications (excludes when your markets close)', detailed: diff --git a/common/scoring.ts b/common/scoring.ts index 4ef46534..a8f62631 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -1,8 +1,9 @@ -import { groupBy, sumBy, mapValues } from 'lodash' +import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash' import { Bet } from './bet' -import { getContractBetMetrics } from './calculate' +import { getContractBetMetrics, resolvedPayout } from './calculate' import { Contract } from './contract' +import { ContractComment } from './comment' export function scoreCreators(contracts: Contract[]) { const creatorScore = mapValues( @@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) { } export function scoreUsersByContract(contract: Contract, bets: Bet[]) { - const betsByUser = groupBy(bets, bet => bet.userId) - return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) + const betsByUser = groupBy(bets, (bet) => bet.userId) + return mapValues( + betsByUser, + (bets) => getContractBetMetrics(contract, bets).profit + ) } export function addUserScores( @@ -43,3 +47,47 @@ export function addUserScores( dest[userId] += score } } + +export function scoreCommentorsAndBettors( + contract: Contract, + bets: Bet[], + comments: ContractComment[] +) { + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = betsById[topBetId]?.userName + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + const topCommentBetId = commentsById[topCommentId]?.betId + + return { + topCommentId, + topBetId, + topBettor, + profitById, + commentsById, + betsById, + topCommentBetId, + } +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index ae199e77..6b5a448d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -53,7 +53,7 @@ export type notification_preferences = { profit_loss_updates: notification_destination_types[] onboarding_flow: notification_destination_types[] thank_you_for_purchases: notification_destination_types[] - + badges_awarded: notification_destination_types[] opt_out_all: notification_destination_types[] // When adding a new notification preference, use add-new-notification-preference.ts to existing users } @@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = ( onboarding_flow: constructPref(false, false), opt_out_all: [], + badges_awarded: constructPref(true, false), } return defaults } @@ -178,31 +179,44 @@ export const getNotificationDestinationsForUser = ( reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences - let destinations - let subscriptionType: notification_preference | undefined - if (Object.keys(notificationSettings).includes(reason)) { - subscriptionType = reason as notification_preference - destinations = notificationSettings[subscriptionType] - } else { - const key = reason as notification_reason_types - subscriptionType = notificationReasonToSubscriptionType[key] - destinations = subscriptionType - ? notificationSettings[subscriptionType] - : [] - } - const optOutOfAllSettings = notificationSettings['opt_out_all'] - // Your market closure notifications are high priority, opt-out doesn't affect their delivery - const optedOutOfEmail = - optOutOfAllSettings.includes('email') && - subscriptionType !== 'your_contract_closed' - const optedOutOfBrowser = - optOutOfAllSettings.includes('browser') && - subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - return { - sendToEmail: destinations.includes('email') && !optedOutOfEmail, - sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, - unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, - urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + try { + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' + return { + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } + } catch (e) { + // Fail safely + console.log( + `couldn't get notification destinations for type ${reason} for user ${privateUser.id}` + ) + return { + sendToEmail: false, + sendToBrowser: false, + unsubscribeUrl: '', + urlToManageThisNotification: '', + } } } diff --git a/common/user.ts b/common/user.ts index 233fe4cc..f89223d2 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,5 +1,6 @@ import { notification_preferences } from './user-notification-preferences' -import { ENV_CONFIG } from 'common/envs/constants' +import { ENV_CONFIG } from './envs/constants' +import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge' export type User = { id: string @@ -11,7 +12,6 @@ export type User = { // For their user page bio?: string - bannerUrl?: string website?: string twitterHandle?: string discordHandle?: string @@ -51,6 +51,18 @@ export type User = { hasSeenContractFollowModal?: boolean freeMarketsCreated?: number isBannedFromPosting?: boolean + + achievements: { + provenCorrect?: { + badges: ProvenCorrectBadge[] + } + marketCreator?: { + badges: MarketCreatorBadge[] + } + streaker?: { + badges: StreakerBadge[] + } + } } export type PrivateUser = { @@ -81,7 +93,8 @@ export type PortfolioMetrics = { userId: string } -export const MANIFOLD_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_USER_NAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' // TODO: remove. Hardcoding the strings would be better. diff --git a/common/util/parse.ts b/common/util/parse.ts index 7e3774c6..3cd53ef2 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -72,7 +72,6 @@ export const exhibitExts = [ Image, Link, - Mention, Mention.extend({ name: 'contract-mention' }), Iframe, TiptapTweet, diff --git a/firestore.rules b/firestore.rules index 50f93e1f..9ab575cd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -27,7 +27,7 @@ service cloud.firestore { allow read; allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); + .hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); // User referral rules allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() diff --git a/functions/.env.dev b/functions/.env.dev new file mode 100644 index 00000000..b5aae225 --- /dev/null +++ b/functions/.env.dev @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=DEV diff --git a/functions/.env b/functions/.env.prod similarity index 100% rename from functions/.env rename to functions/.env.prod diff --git a/functions/package.json b/functions/package.json index 0397c5db..cd2a9ec5 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 9bd73d05..a0134634 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -6,7 +6,12 @@ import { Notification, notification_reason_types, } from '../../common/notification' -import { User } from '../../common/user' +import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USER_NAME, + MANIFOLD_USER_USERNAME, + User, +} from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' @@ -30,6 +35,7 @@ import { import { filterDefined } from '../../common/util/array' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { ContractFollow } from '../../common/follow' +import { Badge } from 'common/badge' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -1087,6 +1093,43 @@ export const createBountyNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) - - // maybe TODO: send email notification to comment creator +} + +export const createBadgeAwardedNotification = async ( + user: User, + badge: Badge +) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'badges_awarded' + ) + if (!sendToBrowser) return + + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: user.id, + reason: 'badges_awarded', + createdTime: Date.now(), + isSeen: false, + sourceId: badge.type, + sourceType: 'badge', + sourceUpdateType: 'created', + sourceUserName: MANIFOLD_USER_NAME, + sourceUserUsername: MANIFOLD_USER_USERNAME, + sourceUserAvatarUrl: MANIFOLD_AVATAR_URL, + sourceText: `You earned a new ${badge.name} badge!`, + sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`, + sourceTitle: badge.name, + data: { + badge, + }, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index c3b7ba1d..d22b8a2e 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, fractionResolvedCorrectly: 1, + achievements: {}, } await firestore.collection('users').doc(auth.uid).create(user) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 993fac81..31129b71 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { contractUrl, getUser } from './utils' +import { contractUrl, getUser, log } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' @@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, @@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async ( privateUser: PrivateUser, sendTime: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -279,22 +271,16 @@ export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.thank_you_for_purchases.includes( - 'email' - ) - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'thank_you_for_purchases' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', @@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async ( contractsToSend: Contract[], deliveryTime?: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.trending_markets.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'trending_markets' ) + if (!sendToEmail) return const { name } = user const firstName = name.split(' ')[0] @@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async ( investments: PerContractInvestmentsData[], overallPerformance: OverallPerformanceData ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.profit_loss_updates.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'profit_loss_updates' ) + if (!sendToEmail) return + const { name } = user const firstName = name.split(' ')[0] const templateData: Record = { @@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async ( : 'portfolio-update', templateData ) + log('Sent portfolio update email to', privateUser.email) } diff --git a/functions/src/index.ts b/functions/src/index.ts index 14b029a8..b64155a3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export { updateMetrics } from './update-metrics' +export { scheduleUpdateMetrics } from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 37214704..0175a63e 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -12,6 +12,7 @@ import { revalidateStaticProps, } from './utils' import { + createBadgeAwardedNotification, createBetFillNotification, createBettingStreakBonusNotification, createUniqueBettorBonusNotification, @@ -35,6 +36,10 @@ import { User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' import { addHouseSubsidy } from './helpers/add-house-subsidy' +import { + StreakerBadge, + streakerBadgeRarityThresholds, +} from '../../common/badge' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -145,7 +150,7 @@ const updateBettingStreak = async ( log('message:', result.message) return } - if (result.txn) + if (result.txn) { await createBettingStreakBonusNotification( user, result.txn.id, @@ -155,6 +160,8 @@ const updateBettingStreak = async ( newBettingStreak, eventId ) + await handleBettingStreakBadgeAward(user, newBettingStreak) + } } const updateUniqueBettorsAndGiveCreatorBonus = async ( @@ -307,3 +314,39 @@ const notifyFills = async ( const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } + +async function handleBettingStreakBadgeAward( + user: User, + newBettingStreak: number +) { + const alreadyHasBadgeForFirstStreak = + user.achievements?.streaker?.badges.some( + (badge) => badge.data.totalBettingStreak === 1 + ) + // TODO: check if already awarded 50th streak as well + if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return + + if (newBettingStreak in streakerBadgeRarityThresholds) { + const badge = { + type: 'STREAKER', + name: 'Streaker', + data: { + totalBettingStreak: newBettingStreak, + }, + createdTime: Date.now(), + } as StreakerBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + streaker: { + badges: [...(user.achievements?.streaker?.badges ?? []), badge], + }, + }, + }) + await createBadgeAwardedNotification(user, badge) + } +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index b613142b..13a84575 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,11 +1,20 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createNewContractNotification } from './create-notification' +import { getUser, getValues } from './utils' +import { + createBadgeAwardedNotification, + createNewContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' import { addUserToContractFollowers } from './follow-market' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, +} from '../../common/badge' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -28,4 +37,43 @@ export const onCreateContract = functions richTextToString(desc), mentioned ) + await handleMarketCreatorBadgeAward(contractCreator) }) + +const firestore = admin.firestore() + +async function handleMarketCreatorBadgeAward(contractCreator: User) { + // get all contracts by user and calculate size of array + const contracts = await getValues( + firestore + .collection(`contracts`) + .where('creatorId', '==', contractCreator.id) + .where('resolution', '!=', 'CANCEL') + ) + if (contracts.length in marketCreatorBadgeRarityThresholds) { + const badge = { + type: 'MARKET_CREATOR', + name: 'Market Creator', + data: { + totalContractsCreated: contracts.length, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + // update user + await firestore + .collection('users') + .doc(contractCreator.id) + .update({ + achievements: { + ...contractCreator.achievements, + marketCreator: { + badges: [ + ...(contractCreator.achievements?.marketCreator?.badges ?? []), + badge, + ], + }, + }, + }) + await createBadgeAwardedNotification(contractCreator, badge) + } +} diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 1e3418fa..f0aa0252 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,9 +1,19 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { getUser, getValues } from './utils' +import { + createBadgeAwardedNotification, + createCommentOrAnswerOrUpdatedContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' -import { GroupContractDoc } from '../../common/group' +import { Bet } from '../../common/bet' import * as admin from 'firebase-admin' +import { ContractComment } from '../../common/comment' +import { scoreCommentorsAndBettors } from '../../common/scoring' +import { + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE, + ProvenCorrectBadge, +} from '../../common/badge' +import { GroupContractDoc } from '../../common/group' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') @@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore if (!previousContract.isResolved && contract.isResolved) { // No need to notify users of resolution, that's handled in resolve-market - return + return await handleResolvedContract(contract) } else if (previousContract.groupSlugs !== contract.groupSlugs) { await handleContractGroupUpdated(previousContract, contract) } else if ( @@ -26,6 +36,63 @@ export const onUpdateContract = functions.firestore } }) +async function handleResolvedContract(contract: Contract) { + if ( + (contract.uniqueBettorCount ?? 0) < + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE + ) + return + + // get all bets on this contract + const bets = await getValues( + firestore.collection(`contracts/${contract.id}/bets`) + ) + + // get comments on this contract + const comments = await getValues( + firestore.collection(`contracts/${contract.id}/comments`) + ) + + const { topCommentId, profitById, commentsById, betsById, topCommentBetId } = + scoreCommentorsAndBettors(contract, bets, comments) + if (topCommentBetId && profitById[topCommentBetId] > 0) { + // award proven correct badge to user + const comment = commentsById[topCommentId] + const bet = betsById[topCommentBetId] + + const user = await getUser(comment.userId) + if (!user) return + const newProvenCorrectBadge = { + createdTime: Date.now(), + type: 'PROVEN_CORRECT', + name: 'Proven Correct', + data: { + contractSlug: contract.slug, + contractCreatorUsername: contract.creatorUsername, + commentId: comment.id, + betAmount: bet.amount, + contractTitle: contract.question, + }, + } as ProvenCorrectBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + provenCorrect: { + badges: [ + ...(user.achievements?.provenCorrect?.badges ?? []), + newProvenCorrectBadge, + ], + }, + }, + }) + await createBadgeAwardedNotification(user, newProvenCorrectBadge) + } +} + async function handleUpdatedCloseTime( previousContract: Contract, contract: Contract, diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index d7e7072b..f72692f7 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -11,15 +11,18 @@ async function main() { await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() - return firestore - .collection('private-users') - .doc(privateUser.id) - .update({ - notificationPreferences: { - ...privateUser.notificationPreferences, - opt_out_all: [], - }, - }) + if (privateUser.notificationPreferences.badges_awarded === undefined) { + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + badges_awarded: ['browser'], + }, + }) + } + return }) ) } diff --git a/functions/src/scripts/backfill-badges.ts b/functions/src/scripts/backfill-badges.ts new file mode 100644 index 00000000..145f064c --- /dev/null +++ b/functions/src/scripts/backfill-badges.ts @@ -0,0 +1,102 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllUsers, getValues } from '../utils' +import { Contract } from 'common/contract' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, + StreakerBadge, + streakerBadgeRarityThresholds, +} from 'common/badge' +import { User } from 'common/user' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const users = await getAllUsers() + // const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian + // const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian + await Promise.all( + users.map(async (user) => { + if (!user.id) return + // Only backfill users without achievements + if (user.achievements === undefined) { + await firestore.collection('users').doc(user.id).update({ + achievements: {}, + }) + user.achievements = {} + user.achievements = await awardMarketCreatorBadges(user) + user.achievements = await awardBettingStreakBadges(user) + console.log('Added achievements to user', user.id) + // going to ignore backfilling the proven correct badges for now + } + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) + +async function awardMarketCreatorBadges(user: User) { + // Award market maker badges + const contracts = await getValues( + firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('resolution', '!=', 'CANCEL') + ) + + const achievements = { + ...user.achievements, + marketCreator: { + badges: [...(user.achievements.marketCreator?.badges ?? [])], + }, + } + for (const threshold of marketCreatorBadgeRarityThresholds) { + if (contracts.length >= threshold) { + const badge = { + type: 'MARKET_CREATOR', + name: 'Market Creator', + data: { + totalContractsCreated: threshold, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + achievements.marketCreator.badges.push(badge) + } + } + // update user + await firestore.collection('users').doc(user.id).update({ + achievements, + }) + return achievements +} + +async function awardBettingStreakBadges(user: User) { + const streak = user.currentBettingStreak ?? 0 + const achievements = { + ...user.achievements, + streaker: { + badges: [...(user.achievements?.streaker?.badges ?? [])], + }, + } + for (const threshold of streakerBadgeRarityThresholds) { + if (streak >= threshold) { + const badge = { + type: 'STREAKER', + name: 'Streaker', + data: { + totalBettingStreak: threshold, + }, + createdTime: Date.now(), + } as StreakerBadge + achievements.streaker.badges.push(badge) + } + } + // update user + await firestore.collection('users').doc(user.id).update({ + achievements, + }) + return achievements +} diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 4739dcc1..106ed773 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' +import { groupBy, keyBy, sortBy } from 'lodash' import fetch from 'node-fetch' import { getValues, log, logMemory, writeAsync } from './utils' @@ -15,6 +15,7 @@ import { calculateNewPortfolioMetrics, calculateNewProfit, calculateProbChanges, + calculateMetricsByContract, computeElasticity, computeVolume, } from '../../common/calculate-metrics' @@ -23,13 +24,15 @@ import { Group } from '../../common/group' import { batchedWaitAll } from '../../common/util/promise' import { newEndpointNoAuth } from './api' import { getFunctionUrl } from '../../common/api' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() - -export const updateMetrics = functions.pubsub +export const scheduleUpdateMetrics = functions.pubsub .schedule('every 15 minutes') .onRun(async () => { - const response = await fetch(getFunctionUrl('updatemetrics'), { + const url = getFunctionUrl('updatemetrics') + console.log('Scheduling update metrics', url) + const response = await fetch(url, { headers: { 'Content-Type': 'application/json', }, @@ -59,11 +62,7 @@ export async function updateMetricsCore() { const contracts = await getValues(firestore.collection('contracts')) console.log('Loading portfolio history') - const allPortfolioHistories = await getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ) + const userPortfolioHistory = await loadPortfolioHistory(users) console.log('Loading groups') const groups = await getValues(firestore.collection('groups')) @@ -140,11 +139,10 @@ export async function updateMetricsCore() { ) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const betsByUser = groupBy(bets, (bet) => bet.userId) - const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId) const userMetrics = users.map((user) => { const currentBets = betsByUser[user.id] ?? [] - const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] + const portfolioHistory = userPortfolioHistory[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? [] const newCreatorVolume = calculateCreatorVolume(userContracts) const newPortfolio = calculateNewPortfolioMetrics( @@ -152,14 +150,20 @@ export async function updateMetricsCore() { contractsById, currentBets ) - const lastPortfolio = last(portfolioHistory) + const currPortfolio = portfolioHistory.current const didPortfolioChange = - lastPortfolio === undefined || - lastPortfolio.balance !== newPortfolio.balance || - lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || - lastPortfolio.investmentValue !== newPortfolio.investmentValue + currPortfolio === undefined || + currPortfolio.balance !== newPortfolio.balance || + currPortfolio.totalDeposits !== newPortfolio.totalDeposits || + currPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + + const metricsByContract = calculateMetricsByContract( + currentBets, + contractsById + ) + const contractRatios = userContracts .map((contract) => { if ( @@ -190,6 +194,7 @@ export async function updateMetricsCore() { newProfit, didPortfolioChange, newFractionResolvedCorrectly, + metricsByContract, } }) @@ -205,63 +210,61 @@ export async function updateMetricsCore() { const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const userUpdates = userMetrics.map( - ({ - user, - newCreatorVolume, - newPortfolio, - newProfit, - didPortfolioChange, - newFractionResolvedCorrectly, - }) => { + ({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { - fieldUpdates: { - doc: firestore.collection('users').doc(user.id), - fields: { - creatorVolumeCached: newCreatorVolume, - profitCached: newProfit, - nextLoanCached, - fractionResolvedCorrectly: newFractionResolvedCorrectly, - }, - }, - - subcollectionUpdates: { - doc: firestore - .collection('users') - .doc(user.id) - .collection('portfolioHistory') - .doc(), - fields: didPortfolioChange ? newPortfolio : {}, + doc: firestore.collection('users').doc(user.id), + fields: { + creatorVolumeCached: newCreatorVolume, + profitCached: newProfit, + nextLoanCached, + fractionResolvedCorrectly: newFractionResolvedCorrectly, }, } } ) - await writeAsync( - firestore, - userUpdates.map((u) => u.fieldUpdates) + await writeAsync(firestore, userUpdates) + + const portfolioHistoryUpdates = filterDefined( + userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => { + return didPortfolioChange + ? { + doc: firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .doc(), + fields: newPortfolio, + } + : null + }) ) - await writeAsync( - firestore, - userUpdates - .filter((u) => !isEmpty(u.subcollectionUpdates.fields)) - .map((u) => u.subcollectionUpdates), - 'set' + await writeAsync(firestore, portfolioHistoryUpdates, 'set') + + const contractMetricsUpdates = userMetrics.flatMap( + ({ user, metricsByContract }) => { + const collection = firestore + .collection('users') + .doc(user.id) + .collection('contract-metrics') + return metricsByContract.map((metrics) => ({ + doc: collection.doc(metrics.contractId), + fields: metrics, + })) + } ) + + await writeAsync(firestore, contractMetricsUpdates, 'set') + log(`Updated metrics for ${users.length} users.`) try { const groupUpdates = groups.map((group, index) => { const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds - .map((e) => contractsById[e.contractId]) - .filter((e) => e !== undefined) as Contract[] - const bets = groupContracts.map((e) => { - if (e != null && e.id in betsByContract) { - return betsByContract[e.id] ?? [] - } else { - return [] - } - }) + const groupContracts = filterDefined( + groupContractIds.map((e) => contractsById[e.contractId]) + ) + const bets = groupContracts.map((e) => betsByContract[e.id] ?? []) const creatorScores = scoreCreators(groupContracts) const traderScores = scoreTraders(groupContracts, bets) @@ -295,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => { type GroupContractDoc = { contractId: string; createdTime: number } const BAD_RESOLUTION_THRESHOLD = 0.1 + +const loadPortfolioHistory = async (users: User[]) => { + const now = Date.now() + const userPortfolioHistory = await batchedWaitAll( + users.map((user) => async () => { + const query = firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .orderBy('timestamp', 'desc') + .limit(1) + + const portfolioMetrics = await Promise.all([ + getValues(query), + getValues( + query.where('timestamp', '<', now - DAY_MS) + ), + getValues( + query.where('timestamp', '<', now - 7 * DAY_MS) + ), + getValues( + query.where('timestamp', '<', now - 30 * DAY_MS) + ), + ]) + const [current, day, week, month] = portfolioMetrics.map( + (p) => p[0] as PortfolioMetrics | undefined + ) + + return { + userId: user.id, + current, + day, + week, + month, + } + }), + 100 + ) + + return keyBy(userPortfolioHistory, (p) => p.userId) +} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 91f4b293..e0cd269a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => { return users.docs.map((doc) => doc.data() as PrivateUser) } +export const getAllUsers = async () => { + const firestore = admin.firestore() + const users = await firestore.collection('users').get() + return users.docs.map((doc) => doc.data() as User) +} + export const getUserByUsername = async (username: string) => { const firestore = admin.firestore() const snap = await firestore diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index bcf6da17..215694eb 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { ) ) ) - log('Found', contractsUsersBetOn.length, 'contracts') - let count = 0 await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) // Don't send to a user unless they're over 5 days old - if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) + return await setEmailFlagAsSent(privateUser.id) const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { (differences) => Math.abs(differences.profit) ).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.profit) > 1 @@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { usersToContractsCreated[privateUser.id].length === 0 ) { log( - 'No bets in last week, no market movers, no markets created. Not sending an email.' + `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .` ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - return + return await setEmailFlagAsSent(privateUser.id) } + // Set the flag beforehand just to be safe + await setEmailFlagAsSent(privateUser.id) 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 users:', count) }) ) } +async function setEmailFlagAsSent(privateUserId: string) { + await firestore.collection('private-users').doc(privateUserId).update({ + weeklyPortfolioUpdateEmailSent: true, + }) +} + export type PerContractInvestmentsData = { questionTitle: string questionUrl: string diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 65a79c20..8cd43369 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -6,6 +6,7 @@ import { Col } from './layout/col' import { ENV_CONFIG } from 'common/envs/constants' import { Row } from './layout/row' import { AddFundsModal } from './add-funds-modal' +import { Input } from './input' export function AmountInput(props: { amount: number | undefined @@ -44,9 +45,9 @@ export function AmountInput(props: { {label} - {!wasResolvedTo && (showChoice === 'checkbox' ? ( -
Add your answer
-