From b26648c1cec22af62b43a319e8f1fabb2cb1fc12 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 5 Jul 2022 11:29:26 -0600 Subject: [PATCH 01/10] Daily trading bonuses (#618) * first commit, WIP * Give trading bonuses & paginate notifications * Move read & update into transaction * Move request bonus logic to notifs icon --- common/notification.ts | 2 + common/numeric-constants.ts | 1 + common/txn.ts | 11 +- common/user.ts | 1 + functions/src/create-notification.ts | 15 + functions/src/get-daily-bonuses.ts | 139 ++++++++ functions/src/index.ts | 1 + web/components/nav/sidebar.tsx | 1 - web/components/notifications-icon.tsx | 20 +- web/hooks/use-notifications.ts | 39 ++- web/lib/firebase/api-call.ts | 4 + web/pages/notifications.tsx | 448 ++++++++++++++++++-------- 12 files changed, 525 insertions(+), 157 deletions(-) create mode 100644 functions/src/get-daily-bonuses.ts diff --git a/common/notification.ts b/common/notification.ts index 64a00a36..e90624a4 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -34,6 +34,7 @@ export type notification_source_types = | 'admin_message' | 'group' | 'user' + | 'bonus' export type notification_source_update_types = | 'created' @@ -56,3 +57,4 @@ export type notification_reason_types = | 'added_you_to_group' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' + | 'unique_bettors_on_your_contract' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index ef364b74..46885668 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,3 +3,4 @@ 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 = 5 diff --git a/common/txn.ts b/common/txn.ts index 0e772e0d..53b08501 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink | Referral +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,7 +16,8 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -52,6 +53,12 @@ type Referral = { category: 'REFERRAL' } +type Bonus = { + fromType: 'BANK' + toType: 'USER' + category: 'UNIQUE_BETTOR_BONUS' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink diff --git a/common/user.ts b/common/user.ts index d5dd0373..477139fd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types + lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index a32ed3bc..b63958f0 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -267,6 +267,15 @@ export const createNotification = async ( } } + const notifyContractCreatorOfUniqueBettorsBonus = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + userToReasonTexts[userId] = { + reason: 'unique_bettors_on_your_contract', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -309,6 +318,12 @@ export const createNotification = async ( }) } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { await notifyContractCreator(userToReasonTexts, sourceContract) + } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) } return userToReasonTexts } diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts new file mode 100644 index 00000000..c5c1a1b3 --- /dev/null +++ b/functions/src/get-daily-bonuses.ts @@ -0,0 +1,139 @@ +import { APIError, newEndpoint } from './api' +import { log } from './utils' +import * as admin from 'firebase-admin' +import { PrivateUser } from '../../common/lib/user' +import { uniq } from 'lodash' +import { Bet } from '../../common/lib/bet' +const firestore = admin.firestore() +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { runTxn, TxnData } from './transact' +import { createNotification } from './create-notification' +import { User } from '../../common/lib/user' +import { Contract } from '../../common/lib/contract' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' + +const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime() +const QUERY_LIMIT_SECONDS = 60 + +export const getdailybonuses = newEndpoint({}, async (req, auth) => { + const { user, lastTimeCheckedBonuses } = await firestore.runTransaction( + async (trans) => { + const userSnap = await trans.get( + firestore.doc(`private-users/${auth.uid}`) + ) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as PrivateUser + const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0 + if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000) + throw new APIError( + 400, + `Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.` + ) + await trans.update(userSnap.ref, { + lastTimeCheckedBonuses: Date.now(), + }) + return { + user, + lastTimeCheckedBonuses, + } + } + ) + // TODO: switch to prod id + // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account + const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + // Get all users contracts made since implementation time + const userContractsSnap = await firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('createdTime', '>=', BONUS_START_DATE) + .get() + const userContracts = userContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + const nullReturn = { status: 'no bets', txn: null } + for (const contract of userContracts) { + const result = await firestore.runTransaction(async (trans) => { + const contractId = contract.id + // Get all bets made on user's contracts + const bets = ( + await firestore + .collection(`contracts/${contractId}/bets`) + .where('userId', '!=', user.id) + .get() + ).docs.map((bet) => bet.ref) + if (bets.length === 0) { + return nullReturn + } + const contractBetsSnap = await trans.getAll(...bets) + const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet) + + const uniqueBettorIdsBeforeLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime < lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter users for ONLY those that have made bets since the last daily bonus received time + const uniqueBettorIdsWithBetsAfterLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime > lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter for users only present in the above list + const newUniqueBettorIds = + uniqueBettorIdsWithBetsAfterLastResetTime.filter( + (userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId) + ) + newUniqueBettorIds.length > 0 && + log( + `Got ${newUniqueBettorIds.length} new unique bettors since last bonus` + ) + if (newUniqueBettorIds.length === 0) { + return nullReturn + } + // Create combined txn for all unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettors: newUniqueBettorIds.length, + } + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + result.status != nullReturn.status && + log(`No bonus for user: ${user.id} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + result.txn.id, + result.txn.amount + '', + contract, + undefined, + // No need to set the user id, we'll use the contract creator id + undefined, + contract.slug, + contract.question + ) + } + } + + return { userId: user.id, message: 'success' } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index b643ff5e..e4a30761 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -38,3 +38,4 @@ export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' +export * from './get-daily-bonuses' diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 5ce9e239..ba46bd80 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -182,7 +182,6 @@ export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() const currentPage = router.pathname - const user = useUser() const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index e2618870..ac4d772f 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' +import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' +import { requestBonuses } from 'web/lib/firebase/api-call' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() - const notifications = usePreferredGroupedNotifications(user?.id, { + const privateUser = usePrivateUser(user?.id) + const notifications = usePreferredGroupedNotifications(privateUser?.id, { unseenOnly: true, }) const [seen, setSeen] = useState(false) + useEffect(() => { + if (!privateUser) return + + if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000) + requestBonuses({}).catch((error) => { + console.log("couldn't get bonuses:", error.message) + }) + }, [privateUser]) + const router = useRouter() useEffect(() => { if (router.pathname.endsWith('notifications')) return setSeen(true) @@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
{!seen && notifications && notifications.length > 0 && (
- {notifications.length} + {notifications.length > NOTIFICATIONS_PER_PAGE + ? `${NOTIFICATIONS_PER_PAGE}+` + : notifications.length}
)} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index c947e8d0..0a15754d 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash' export type NotificationGroup = { notifications: Notification[] - sourceContractId: string + groupedById: string isSeen: boolean timePeriod: string + type: 'income' | 'normal' } export function usePreferredGroupedNotifications( @@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) { new Date(notification.createdTime).toDateString() ) Object.keys(notificationGroupsByDay).forEach((day) => { - // Group notifications by contract: + const notificationsGroupedByDay = notificationGroupsByDay[day] + const bonusNotifications = notificationsGroupedByDay.filter( + (notification) => notification.sourceType === 'bonus' + ) + const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( + (notification) => notification.sourceType !== 'bonus' + ) + if (bonusNotifications.length > 0) { + notificationGroups = notificationGroups.concat({ + notifications: bonusNotifications, + groupedById: 'income' + day, + isSeen: bonusNotifications[0].isSeen, + timePeriod: day, + type: 'income', + }) + } + // Group notifications by contract, filtering out bonuses: const groupedNotificationsByContractId = groupBy( - notificationGroupsByDay[day], + normalNotificationsGroupedByDay, (notification) => { return notification.sourceContractId } ) notificationGroups = notificationGroups.concat( map(groupedNotificationsByContractId, (notifications, contractId) => { + const notificationsForContractId = groupedNotificationsByContractId[ + contractId + ].sort((a, b) => { + return b.createdTime - a.createdTime + }) // Create a notification group for each contract within each day const notificationGroup: NotificationGroup = { - notifications: groupedNotificationsByContractId[contractId].sort( - (a, b) => { - return b.createdTime - a.createdTime - } - ), - sourceContractId: contractId, - isSeen: groupedNotificationsByContractId[contractId][0].isSeen, + notifications: notificationsForContractId, + groupedById: contractId, + isSeen: notificationsForContractId[0].isSeen, timePeriod: day, + type: 'normal', } return notificationGroup }) diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index e02872ae..db41e592 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -73,3 +73,7 @@ export function sellBet(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function requestBonuses(params: any) { + return call(getFunctionUrl('getdailybonuses'), 'POST', params) +} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index f3512c56..229e8c8d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,12 +1,7 @@ import { Tabs } from 'web/components/layout/tabs' import { useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' -import { - Notification, - notification_reason_types, - notification_source_types, - notification_source_update_types, -} from 'common/notification' +import { Notification } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -31,47 +26,40 @@ import { ProbPercentLabel, } from 'web/components/outcome-label' import { - groupNotifications, NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' +import { groupBy } from 'lodash' + +export const NOTIFICATIONS_PER_PAGE = 30 +export const HIGHLIGHT_DURATION = 30 * 1000 export default function Notifications() { const user = useUser() - const [unseenNotificationGroups, setUnseenNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - const allNotificationGroups = usePreferredGroupedNotifications(user?.id, { + const [page, setPage] = useState(1) + + const groupedNotifications = usePreferredGroupedNotifications(user?.id, { unseenOnly: false, }) - + const [paginatedNotificationGroups, setPaginatedNotificationGroups] = + useState([]) useEffect(() => { - if (!allNotificationGroups) return - // Don't re-add notifications that are visible right now or have been seen already. - const currentlyVisibleUnseenNotificationIds = Object.values( - unseenNotificationGroups ?? [] - ) - .map((n) => n.notifications.map((n) => n.id)) - .flat() - const unseenGroupedNotifications = groupNotifications( - allNotificationGroups - .map((notification: NotificationGroup) => notification.notifications) - .flat() - .filter( - (notification: Notification) => - !notification.isSeen || - currentlyVisibleUnseenNotificationIds.includes(notification.id) - ) - ) - setUnseenNotificationGroups(unseenGroupedNotifications) - - // We don't want unseenNotificationsGroup to be in the dependencies as we update it here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allNotificationGroups]) + if (!groupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = groupedNotifications.slice(start, end) + const remainingNotification = groupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedNotificationGroups(maxNotificationsToShow) + }, [groupedNotifications, page]) if (user === undefined) { return @@ -80,7 +68,6 @@ export default function Notifications() { return } - // TODO: use infinite scroll return (
@@ -90,53 +77,74 @@ export default function Notifications() { defaultIndex={0} tabs={[ { - title: 'New Notifications', - content: unseenNotificationGroups ? ( + title: 'Notifications', + content: groupedNotifications ? (
- {unseenNotificationGroups.length === 0 && - "You don't have any new notifications."} - {unseenNotificationGroups.map((notification) => + {paginatedNotificationGroups.length === 0 && + "You don't have any notifications. Try changing your settings to see more."} + {paginatedNotificationGroups.map((notification) => notification.notifications.length === 1 ? ( + ) : notification.type === 'income' ? ( + ) : ( ) )} -
- ) : ( - - ), - }, - { - title: 'All Notifications', - content: allNotificationGroups ? ( -
- {allNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {allNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - - ) : ( - - ) + {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + )}
) : ( @@ -164,7 +172,6 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { updateDoc( doc(db, `users/${notification.userId}/notifications/`, notification.id), { - ...notification, isSeen: true, viewTime: new Date(), } @@ -173,6 +180,152 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } +function IncomeNotificationGroupItem(props: { + notificationGroup: NotificationGroup + className?: string +}) { + const { notificationGroup, className } = props + const { notifications } = notificationGroup + const numSummaryLines = 3 + + const [expanded, setExpanded] = useState(false) + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + setNotificationsAsSeen(notifications) + }, [notifications]) + + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + + const totalIncome = notifications.reduce( + (acc, notification) => + acc + + (notification.sourceType && + notification.sourceText && + notification.sourceType === 'bonus' + ? parseInt(notification.sourceText) + : 0), + 0 + ) + // loop through the contracts and combine the notification items into one + function combineNotificationsByAddingSourceTextsAndReturningTheRest( + notifications: Notification[] + ) { + const newNotifications = [] + const groupedNotificationsByContractId = groupBy( + notifications, + (notification) => { + return notification.sourceContractId + } + ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + + const newNotification = + notificationsForContractId.length === 1 + ? notificationsForContractId[0] + : { + ...notificationsForContractId[0], + sourceText: sum.toString(), + } + newNotifications.push(newNotification) + } + return newNotifications + } + + const combinedNotifs = + combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications) + + return ( +
setExpanded(!expanded)} + > + {expanded && ( +