diff --git a/common/notification.ts b/common/notification.ts index 8ced082a..0a69f89d 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -38,6 +38,7 @@ export type notification_source_types = | 'user' | 'bonus' | 'challenge' + | 'betting_streak_bonus' | 'loan' export type notification_source_update_types = @@ -67,4 +68,5 @@ export type notification_reason_types = | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' + | 'betting_streak_incremented' | 'loan_income' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index f399aa5a..9d41d54f 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -4,3 +4,5 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 +export const BETTING_STREAK_BONUS_AMOUNT = 5 +export const BETTING_STREAK_RESET_HOUR = 9 diff --git a/common/txn.ts b/common/txn.ts index 701b67fe..00b19570 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -16,7 +16,13 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + category: + | 'CHARITY' + | 'MANALINK' + | 'TIP' + | 'REFERRAL' + | 'UNIQUE_BETTOR_BONUS' + | 'BETTING_STREAK_BONUS' // Any extra data data?: { [key: string]: any } @@ -57,7 +63,7 @@ type Referral = { type Bonus = { fromType: 'BANK' toType: 'USER' - category: 'UNIQUE_BETTOR_BONUS' + category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' } export type DonationTxn = Txn & Donation diff --git a/common/user.ts b/common/user.ts index 2aeb7122..8ad4c91b 100644 --- a/common/user.ts +++ b/common/user.ts @@ -41,6 +41,8 @@ export type User = { referredByGroupId?: string lastPingTime?: number shouldShowWelcome?: boolean + lastBetTime?: number + currentBettingStreak?: number } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index d1a589a0..979e1305 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -524,3 +524,38 @@ export const createChallengeAcceptedNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createBettingStreakBonusNotification = async ( + user: User, + txnId: string, + bet: Bet, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: user.id, + reason: 'betting_streak_incremented', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'betting_streak_bonus', + sourceUpdateType: 'created', + sourceUserName: user.name, + sourceUserUsername: user.username, + sourceUserAvatarUrl: user.avatarUrl, + sourceText: amount.toString(), + sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`, + sourceTitle: 'Betting Streak Bonus', + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 6d1f0c01..fb0e63bf 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -26,6 +26,7 @@ export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' +export * from './reset-betting-streaks' // v2 export * from './health' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index d33e71dd..c5648293 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -3,15 +3,20 @@ import * as admin from 'firebase-admin' import { keyBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' -import { getContract, getUser, getValues, isProd, log } from './utils' +import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, + createBettingStreakBonusNotification, createNotification, } from './create-notification' import { filterDefined } from '../../common/util/array' import { Contract } from '../../common/contract' import { runTxn, TxnData } from './transact' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' +import { + BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_RESET_HOUR, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from '../../common/numeric-constants' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -38,37 +43,99 @@ export const onCreateBet = functions.firestore .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) - await notifyFills(bet, contractId, eventId) - await updateUniqueBettorsAndGiveCreatorBonus( - contractId, - eventId, - bet.userId - ) + const userContractSnap = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + const contract = userContractSnap.data() as Contract + + if (!contract) { + log(`Could not find contract ${contractId}`) + return + } + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) + + const bettor = await getUser(bet.userId) + if (!bettor) return + + await notifyFills(bet, contract, eventId, bettor) + await updateBettingStreak(bettor, bet, contract, eventId) + + await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) }) +const updateBettingStreak = async ( + user: User, + bet: Bet, + contract: Contract, + eventId: string +) => { + const betStreakResetTime = getTodaysBettingStreakResetTime() + const lastBetTime = user?.lastBetTime ?? 0 + + // If they've already bet after the reset time, or if we haven't hit the reset time yet + if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) + return + + const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 + // Otherwise, add 1 to their betting streak + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: newBettingStreak, + }) + + // Send them the bonus times their streak + const bonusAmount = Math.min( + BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, + 100 + ) + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const bonusTxnDetails = { + currentBettingStreak: newBettingStreak, + } + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: fromUserId, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: bonusAmount, + token: 'M$', + category: 'BETTING_STREAK_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + if (!result.txn) { + log("betting streak bonus txn couldn't be made") + return + } + + await createBettingStreakBonusNotification( + user, + result.txn.id, + bet, + contract, + bonusAmount, + eventId + ) +} + const updateUniqueBettorsAndGiveCreatorBonus = async ( - contractId: string, + contract: Contract, eventId: string, bettorId: string ) => { - const userContractSnap = await firestore - .collection(`contracts`) - .doc(contractId) - .get() - const contract = userContractSnap.data() as Contract - if (!contract) { - log(`Could not find contract ${contractId}`) - return - } let previousUniqueBettorIds = contract.uniqueBettorIds if (!previousUniqueBettorIds) { const contractBets = ( - await firestore.collection(`contracts/${contractId}/bets`).get() + await firestore.collection(`contracts/${contract.id}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { - log(`No bets for contract ${contractId}`) + log(`No bets for contract ${contract.id}`) return } @@ -86,7 +153,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) - await firestore.collection(`contracts`).doc(contractId).update({ + await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, }) @@ -97,7 +164,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // Create combined txn for all new unique bettors const bonusTxnDetails = { - contractId: contractId, + contractId: contract.id, uniqueBettorIds: newUniqueBettorIds, } const fromUserId = isProd() @@ -140,14 +207,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } } -const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { +const notifyFills = async ( + bet: Bet, + contract: Contract, + eventId: string, + user: User +) => { if (!bet.fills) return - const user = await getUser(bet.userId) - if (!user) return - const contract = await getContract(contractId) - if (!contract) return - const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) const matchedBets = ( await Promise.all( @@ -180,3 +247,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { }) ) } + +const getTodaysBettingStreakResetTime = () => { + return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) +} diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts new file mode 100644 index 00000000..0600fa56 --- /dev/null +++ b/functions/src/reset-betting-streaks.ts @@ -0,0 +1,38 @@ +// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak + +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants' +const firestore = admin.firestore() + +export const resetBettingStreaksForUsers = functions.pubsub + .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) + .onRun(async () => { + await resetBettingStreaksInternal() + }) + +const resetBettingStreaksInternal = async () => { + const usersSnap = await firestore.collection('users').get() + + const users = usersSnap.docs.map((doc) => doc.data() as User) + + for (const user of users) { + await resetBettingStreakForUser(user) + } +} + +const resetBettingStreakForUser = async (user: User) => { + const betStreakResetTime = Date.now() - DAY_MS + // if they made a bet within the last day, don't reset their streak + if ( + (user.lastBetTime ?? 0 > betStreakResetTime) || + !user.currentBettingStreak || + user.currentBettingStreak === 0 + ) + return + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: 0, + }) +} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 426a9371..cb071850 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -3,7 +3,6 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' -import { Spacer } from './layout/spacer' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' @@ -37,7 +36,7 @@ export function AmountInput(props: { return ( -