import * as admin from 'firebase-admin' import { getDestinationsForUser, Notification, notification_reason_types, } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group } from '../../common/group' import { Challenge } from '../../common/challenge' import { Like } from '../../common/like' import { sendMarketCloseEmail, sendMarketResolutionEmail, sendNewAnswerEmail, sendNewCommentEmail, sendNewFollowedMarketEmail, } from './emails' import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( sourceId: string, sourceType: 'contract' | 'liquidity' | 'follow', sourceUpdateType: 'closed' | 'created', sourceUser: User, idempotencyKey: string, sourceText: string, miscData?: { contract?: Contract recipients?: string[] slug?: string title?: string } ) => { const { contract: sourceContract, recipients, slug, title } = miscData ?? {} const shouldReceiveNotification = ( userId: string, userToReasonTexts: recipients_to_reason_texts ) => { return ( sourceUser.id != userId && !Object.keys(userToReasonTexts).includes(userId) ) } const sendNotificationsIfSettingsPermit = async ( userToReasonTexts: recipients_to_reason_texts ) => { for (const userId in userToReasonTexts) { const { reason } = userToReasonTexts[userId] const privateUser = await getPrivateUser(userId) if (!privateUser) continue const { sendToBrowser, sendToEmail } = await getDestinationsForUser( privateUser, reason ) if (sendToBrowser) { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, reason, createdTime: Date.now(), isSeen: false, sourceId, sourceType, sourceUpdateType, sourceContractId: sourceContract?.id, sourceUserName: sourceUser.name, sourceUserUsername: sourceUser.username, sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, sourceSlug: slug ? slug : sourceContract?.slug, sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) } if (!sendToEmail) continue if (reason === 'your_contract_closed' && privateUser && sourceContract) { // TODO: include number and names of bettors waiting for creator to resolve their market await sendMarketCloseEmail( reason, sourceUser, privateUser, sourceContract ) } else if (reason === 'subsidized_your_market') { // TODO: send email to creator of market that was subsidized } else if (reason === 'on_new_follow') { // TODO: send email to user who was followed } } } // The following functions modify the userToReasonTexts object in place. const userToReasonTexts: recipients_to_reason_texts = {} if (sourceType === 'follow' && recipients?.[0]) { if (shouldReceiveNotification(recipients[0], userToReasonTexts)) userToReasonTexts[recipients[0]] = { reason: 'on_new_follow', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && sourceContract ) { userToReasonTexts[sourceContract.creatorId] = { reason: 'your_contract_closed', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'liquidity' && sourceUpdateType === 'created' && sourceContract ) { if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) userToReasonTexts[sourceContract.creatorId] = { reason: 'subsidized_your_market', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) } } export type replied_users_info = { [key: string]: { repliedToType: 'comment' | 'answer' repliedToAnswerText: string | undefined repliedToId: string | undefined bet: Bet | undefined } } export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, sourceType: 'comment' | 'answer' | 'contract', sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUser: User, idempotencyKey: string, sourceText: string, sourceContract: Contract, miscData?: { repliedUsersInfo: replied_users_info taggedUserIds: string[] }, resolutionData?: { bets: Bet[] userInvestments: { [userId: string]: number } userPayouts: { [userId: string]: number } creator: User creatorPayout: number contract: Contract outcome: string resolutionProbability?: number resolutions?: { [outcome: string]: number } } ) => { const { repliedUsersInfo, taggedUserIds } = miscData ?? {} const browserRecipientIdsList: string[] = [] const emailRecipientIdsList: string[] = [] const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) .get() const contractFollowersIds = contractFollowersSnap.docs.map( (doc) => doc.data().id ) const createBrowserNotification = async ( userId: string, reason: notification_reason_types ) => { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, reason, createdTime: Date.now(), isSeen: false, sourceId, sourceType, sourceUpdateType, sourceContractId: sourceContract.id, sourceUserName: sourceUser.name, sourceUserUsername: sourceUser.username, sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, sourceContractCreatorUsername: sourceContract.creatorUsername, sourceContractTitle: sourceContract.question, sourceContractSlug: sourceContract.slug, sourceSlug: sourceContract.slug, sourceTitle: sourceContract.question, } return await notificationRef.set(removeUndefinedProps(notification)) } const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } const sendNotificationsIfSettingsPermit = async ( userId: string, reason: notification_reason_types ) => { if ( !stillFollowingContract(sourceContract.creatorId) || sourceUser.id == userId ) return const privateUser = await getPrivateUser(userId) if (!privateUser) return const { sendToBrowser, sendToEmail } = await getDestinationsForUser( privateUser, reason ) // Browser notifications if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { await createBrowserNotification(userId, reason) browserRecipientIdsList.push(userId) } // Emails notifications if (!sendToEmail || emailRecipientIdsList.includes(userId)) return if (sourceType === 'comment') { const { repliedToType, repliedToAnswerText, repliedToId, bet } = repliedUsersInfo?.[userId] ?? {} // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment await sendNewCommentEmail( reason, privateUser, sourceUser, sourceContract, sourceText, sourceId, bet, repliedToAnswerText, repliedToType === 'answer' ? repliedToId : undefined ) emailRecipientIdsList.push(userId) } else if (sourceType === 'answer') { await sendNewAnswerEmail( reason, privateUser, sourceUser.name, sourceText, sourceContract, sourceUser.avatarUrl ) emailRecipientIdsList.push(userId) } else if ( sourceType === 'contract' && sourceUpdateType === 'resolved' && resolutionData ) { await sendMarketResolutionEmail( reason, privateUser, resolutionData.userInvestments[userId] ?? 0, resolutionData.userPayouts[userId] ?? 0, sourceUser, resolutionData.creatorPayout, sourceContract, resolutionData.outcome, resolutionData.resolutionProbability, resolutionData.resolutions ) emailRecipientIdsList.push(userId) } } const notifyContractFollowers = async () => { for (const userId of contractFollowersIds) { await sendNotificationsIfSettingsPermit( userId, sourceType === 'answer' ? 'answer_on_contract_you_follow' : sourceType === 'comment' ? 'comment_on_contract_you_follow' : sourceUpdateType === 'updated' ? 'update_on_contract_you_follow' : 'resolution_on_contract_you_follow' ) } } const notifyContractCreator = async () => { await sendNotificationsIfSettingsPermit( sourceContract.creatorId, sourceType === 'comment' ? 'comment_on_your_contract' : 'answer_on_your_contract' ) } const notifyOtherAnswerersOnContract = async () => { const answers = await getValues( firestore .collection('contracts') .doc(sourceContract.id) .collection('answers') ) const recipientUserIds = uniq(answers.map((answer) => answer.userId)) await Promise.all( recipientUserIds.map((userId) => sendNotificationsIfSettingsPermit( userId, sourceType === 'answer' ? 'answer_on_contract_with_users_answer' : sourceType === 'comment' ? 'comment_on_contract_with_users_answer' : sourceUpdateType === 'updated' ? 'update_on_contract_with_users_answer' : 'resolution_on_contract_with_users_answer' ) ) ) } const notifyOtherCommentersOnContract = async () => { const comments = await getValues( firestore .collection('contracts') .doc(sourceContract.id) .collection('comments') ) const recipientUserIds = uniq(comments.map((comment) => comment.userId)) await Promise.all( recipientUserIds.map((userId) => sendNotificationsIfSettingsPermit( userId, sourceType === 'answer' ? 'answer_on_contract_with_users_comment' : sourceType === 'comment' ? 'comment_on_contract_with_users_comment' : sourceUpdateType === 'updated' ? 'update_on_contract_with_users_comment' : 'resolution_on_contract_with_users_comment' ) ) ) } const notifyBettorsOnContract = async () => { const betsSnap = await firestore .collection(`contracts/${sourceContract.id}/bets`) .get() const bets = betsSnap.docs.map((doc) => doc.data() as Bet) // filter bets for only users that have an amount invested still const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( (userId) => { return ( getContractBetMetrics( sourceContract, bets.filter((bet) => bet.userId === userId) ).invested > 0 ) } ) await Promise.all( recipientUserIds.map((userId) => sendNotificationsIfSettingsPermit( userId, sourceType === 'answer' ? 'answer_on_contract_with_users_shares_in' : sourceType === 'comment' ? 'comment_on_contract_with_users_shares_in' : sourceUpdateType === 'updated' ? 'update_on_contract_with_users_shares_in' : 'resolution_on_contract_with_users_shares_in' ) ) ) } const notifyRepliedUser = async () => { if (sourceType === 'comment' && repliedUsersInfo) await Promise.all( Object.keys(repliedUsersInfo).map((userId) => sendNotificationsIfSettingsPermit( userId, repliedUsersInfo[userId].repliedToType === 'answer' ? 'reply_to_users_answer' : 'reply_to_users_comment' ) ) ) } const notifyTaggedUsers = async () => { if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0) await Promise.all( taggedUserIds.map((userId) => sendNotificationsIfSettingsPermit(userId, 'tagged_user') ) ) } const notifyLiquidityProviders = async () => { const liquidityProviders = await firestore .collection(`contracts/${sourceContract.id}/liquidity`) .get() const liquidityProvidersIds = uniq( liquidityProviders.docs.map((doc) => doc.data().userId) ) await Promise.all( liquidityProvidersIds.map((userId) => sendNotificationsIfSettingsPermit( userId, sourceType === 'answer' ? 'answer_on_contract_with_users_shares_in' : sourceType === 'comment' ? 'comment_on_contract_with_users_shares_in' : sourceUpdateType === 'updated' ? 'update_on_contract_with_users_shares_in' : 'resolution_on_contract_with_users_shares_in' ) ) ) } await notifyRepliedUser() await notifyTaggedUsers() await notifyContractCreator() await notifyOtherAnswerersOnContract() await notifyLiquidityProviders() await notifyBettorsOnContract() await notifyOtherCommentersOnContract() // if they weren't notified previously, notify them now await notifyContractFollowers() } export const createTipNotification = async ( fromUser: User, toUser: User, tip: TipTxn, idempotencyKey: string, commentId: string, contract?: Contract, group?: Group ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'tip_received' ) if (!sendToBrowser) return const slug = group ? group.slug + `#${commentId}` : commentId const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, reason: 'tip_received', createdTime: Date.now(), isSeen: false, sourceId: tip.id, sourceType: 'tip', sourceUpdateType: 'created', sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, sourceText: tip.amount.toString(), sourceContractCreatorUsername: contract?.creatorUsername, sourceContractTitle: contract?.question, sourceContractSlug: contract?.slug, sourceSlug: slug, sourceTitle: group?.name, } return await notificationRef.set(removeUndefinedProps(notification)) // TODO: send notification to users that are watching the contract and want highly tipped comments only // maybe TODO: send email notification to bet creator } export const createBetFillNotification = async ( fromUser: User, toUser: User, bet: Bet, userBet: LimitBet, contract: Contract, idempotencyKey: string ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'bet_fill' ) if (!sendToBrowser) return const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, reason: 'bet_fill', createdTime: Date.now(), isSeen: false, sourceId: userBet.id, sourceType: 'bet', sourceUpdateType: 'updated', sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, sourceText: fillAmount.toString(), sourceContractCreatorUsername: contract.creatorUsername, sourceContractTitle: contract.question, sourceContractSlug: contract.slug, sourceContractId: contract.id, } return await notificationRef.set(removeUndefinedProps(notification)) // maybe TODO: send email notification to bet creator } export const createReferralNotification = async ( toUser: User, referredUser: User, idempotencyKey: string, bonusAmount: string, referredByContract?: Contract, referredByGroup?: Group ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'you_referred_user' ) if (!sendToBrowser) return const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, reason: referredByGroup ? 'user_joined_from_your_group_invite' : referredByContract?.creatorId === toUser.id ? 'user_joined_to_bet_on_your_market' : 'you_referred_user', createdTime: Date.now(), isSeen: false, sourceId: referredUser.id, sourceType: 'user', sourceUpdateType: 'updated', sourceContractId: referredByContract?.id, sourceUserName: referredUser.name, sourceUserUsername: referredUser.username, sourceUserAvatarUrl: referredUser.avatarUrl, sourceText: bonusAmount, // Only pass the contract referral details if they weren't referred to a group sourceContractCreatorUsername: !referredByGroup ? referredByContract?.creatorUsername : undefined, sourceContractTitle: !referredByGroup ? referredByContract?.question : undefined, sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined, sourceSlug: referredByGroup ? groupPath(referredByGroup.slug) : referredByContract?.slug, sourceTitle: referredByGroup ? referredByGroup.name : referredByContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) // TODO send email notification } export const createLoanIncomeNotification = async ( toUser: User, idempotencyKey: string, income: number ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'loan_income' ) if (!sendToBrowser) return const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, reason: 'loan_income', createdTime: Date.now(), isSeen: false, sourceId: idempotencyKey, sourceType: 'loan', sourceUpdateType: 'updated', sourceUserName: toUser.name, sourceUserUsername: toUser.username, sourceUserAvatarUrl: toUser.avatarUrl, sourceText: income.toString(), sourceTitle: 'Loan', } await notificationRef.set(removeUndefinedProps(notification)) } const groupPath = (groupSlug: string) => `/group/${groupSlug}` export const createChallengeAcceptedNotification = async ( challenger: User, challengeCreator: User, challenge: Challenge, acceptedAmount: number, contract: Contract ) => { const privateUser = await getPrivateUser(challengeCreator.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'challenge_accepted' ) if (!sendToBrowser) return const notificationRef = firestore .collection(`/users/${challengeCreator.id}/notifications`) .doc() const notification: Notification = { id: notificationRef.id, userId: challengeCreator.id, reason: 'challenge_accepted', createdTime: Date.now(), isSeen: false, sourceId: challenge.slug, sourceType: 'challenge', sourceUpdateType: 'updated', sourceUserName: challenger.name, sourceUserUsername: challenger.username, sourceUserAvatarUrl: challenger.avatarUrl, sourceText: acceptedAmount.toString(), sourceContractCreatorUsername: contract.creatorUsername, sourceContractTitle: contract.question, sourceContractSlug: contract.slug, sourceContractId: contract.id, sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`, } return await notificationRef.set(removeUndefinedProps(notification)) } export const createBettingStreakBonusNotification = async ( user: User, txnId: string, bet: Bet, contract: Contract, amount: number, idempotencyKey: string ) => { const privateUser = await getPrivateUser(user.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'betting_streak_incremented' ) if (!sendToBrowser) return 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)) } export const createLikeNotification = async ( fromUser: User, toUser: User, like: Like, idempotencyKey: string, contract: Contract, tip?: TipTxn ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'liked_and_tipped_your_contract' ) if (!sendToBrowser) return // not handling just likes, must include tip if (!tip) return const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, reason: 'liked_and_tipped_your_contract', createdTime: Date.now(), isSeen: false, sourceId: like.id, sourceType: tip ? 'tip_and_like' : 'like', sourceUpdateType: 'created', sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, sourceText: tip?.amount.toString(), sourceContractCreatorUsername: contract.creatorUsername, sourceContractTitle: contract.question, sourceContractSlug: contract.slug, sourceSlug: contract.slug, sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) // TODO send email notification } export const createUniqueBettorBonusNotification = async ( contractCreatorId: string, bettor: User, txnId: string, contract: Contract, amount: number, idempotencyKey: string ) => { console.log('createUniqueBettorBonusNotification') const privateUser = await getPrivateUser(contractCreatorId) if (!privateUser) return const { sendToBrowser } = await getDestinationsForUser( privateUser, 'unique_bettors_on_your_contract' ) if (!sendToBrowser) return const notificationRef = firestore .collection(`/users/${contractCreatorId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: contractCreatorId, reason: 'unique_bettors_on_your_contract', createdTime: Date.now(), isSeen: false, sourceId: txnId, sourceType: 'bonus', sourceUpdateType: 'created', sourceUserName: bettor.name, sourceUserUsername: bettor.username, sourceUserAvatarUrl: bettor.avatarUrl, sourceText: amount.toString(), sourceSlug: contract.slug, sourceTitle: contract.question, // 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)) // TODO send email notification } export const createNewContractNotification = async ( contractCreator: User, contract: Contract, idempotencyKey: string, text: string, mentionedUserIds: string[] ) => { if (contract.visibility !== 'public') return const sendNotificationsIfSettingsAllow = async ( userId: string, reason: notification_reason_types ) => { const privateUser = await getPrivateUser(userId) if (!privateUser) return const { sendToBrowser, sendToEmail } = await getDestinationsForUser( privateUser, reason ) if (sendToBrowser) { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: userId, reason, createdTime: Date.now(), isSeen: false, sourceId: contract.id, sourceType: 'contract', sourceUpdateType: 'created', sourceUserName: contractCreator.name, sourceUserUsername: contractCreator.username, sourceUserAvatarUrl: contractCreator.avatarUrl, sourceText: text, sourceSlug: contract.slug, sourceTitle: contract.question, sourceContractSlug: contract.slug, sourceContractId: contract.id, sourceContractTitle: contract.question, sourceContractCreatorUsername: contract.creatorUsername, } await notificationRef.set(removeUndefinedProps(notification)) } if (!sendToEmail) return if (reason === 'contract_from_followed_user') await sendNewFollowedMarketEmail(reason, userId, privateUser, contract) } const followersSnapshot = await firestore .collectionGroup('follows') .where('userId', '==', contractCreator.id) .get() const followerUserIds = filterDefined( followersSnapshot.docs.map((doc) => { const followerUserId = doc.ref.parent.parent?.id return followerUserId && followerUserId != contractCreator.id ? followerUserId : undefined }) ) // As it is coded now, the tag notification usurps the new contract notification // It'd be easy to append the reason to the eventId if desired for (const followerUserId of followerUserIds) { await sendNotificationsIfSettingsAllow( followerUserId, 'contract_from_followed_user' ) } for (const mentionedUserId of mentionedUserIds) { await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') } }