Enrich contract resolved notification

This commit is contained in:
Ian Philips 2022-09-15 15:25:19 -06:00
parent b903183fff
commit 7628713c4b
7 changed files with 243 additions and 88 deletions

View File

@ -2,3 +2,8 @@ export type Follow = {
userId: string userId: string
timestamp: number timestamp: number
} }
export type ContractFollow = {
id: string // user id
createdTime: number
}

View File

@ -248,3 +248,9 @@ export type BetFillData = {
probability: number probability: number
fillAmount: number fillAmount: number
} }
export type ContractResolutionData = {
outcome: string
userPayout: number
userInvestment: number
}

View File

@ -218,6 +218,7 @@ const notificationReasonToSubscriptionType: Partial<
export const getNotificationDestinationsForUser = ( export const getNotificationDestinationsForUser = (
privateUser: PrivateUser, privateUser: PrivateUser,
// TODO: accept reasons array from most to least important and work backwards
reason: notification_reason_types | notification_preference reason: notification_reason_types | notification_preference
) => { ) => {
const notificationSettings = privateUser.notificationPreferences const notificationSettings = privateUser.notificationPreferences

View File

@ -2,6 +2,7 @@ import * as admin from 'firebase-admin'
import { import {
BetFillData, BetFillData,
BettingStreakData, BettingStreakData,
ContractResolutionData,
Notification, Notification,
notification_reason_types, notification_reason_types,
} from '../../common/notification' } from '../../common/notification'
@ -28,6 +29,7 @@ import {
} from './emails' } from './emails'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow'
const firestore = admin.firestore() const firestore = admin.firestore()
type recipients_to_reason_texts = { type recipients_to_reason_texts = {
@ -159,7 +161,7 @@ export type replied_users_info = {
export const createCommentOrAnswerOrUpdatedContractNotification = async ( export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string, sourceId: string,
sourceType: 'comment' | 'answer' | 'contract', sourceType: 'comment' | 'answer' | 'contract',
sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUpdateType: 'created' | 'updated',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
@ -167,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
miscData?: { miscData?: {
repliedUsersInfo: replied_users_info repliedUsersInfo: replied_users_info
taggedUserIds: string[] 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 { repliedUsersInfo, taggedUserIds } = miscData ?? {}
@ -230,11 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string, userId: string,
reason: notification_reason_types reason: notification_reason_types
) => { ) => {
if ( if (!stillFollowingContract(userId) || sourceUser.id == userId) return
!stillFollowingContract(sourceContract.creatorId) ||
sourceUser.id == userId
)
return
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
@ -276,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceUser.avatarUrl sourceUser.avatarUrl
) )
emailRecipientIdsList.push(userId) 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)
} }
} }
@ -447,6 +416,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
) )
} }
//TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they
// have enabled so they will unsubscribe from the least important notifications
await notifyRepliedUser() await notifyRepliedUser()
await notifyTaggedUsers() await notifyTaggedUsers()
await notifyContractCreator() await notifyContractCreator()
@ -943,3 +914,130 @@ export const createNewContractNotification = async (
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
} }
} }
export const createContractResolvedNotifications = async (
contract: Contract,
creator: User,
outcome: string,
probabilityInt: number | undefined,
resolutionValue: number | undefined,
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 }
}
) => {
let resolutionText = outcome ?? contract.question
if (
contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'MULTIPLE_CHOICE'
) {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && resolutionValue)
resolutionText = `${resolutionValue}`
}
const idempotencyKey = contract.id + '-resolved'
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: contract.id,
sourceType: 'contract',
sourceUpdateType: 'resolved',
sourceContractId: contract.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: resolutionText,
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
data: {
outcome,
userInvestment: resolutionData.userInvestments[userId] ?? 0,
userPayout: resolutionData.userPayouts[userId] ?? 0,
} as ContractResolutionData,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
const sendNotificationsIfSettingsPermit = async (
userId: string,
reason: notification_reason_types
) => {
if (!stillFollowingContract(userId) || creator.id == userId) return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
reason
)
// Browser notifications
if (sendToBrowser) {
await createBrowserNotification(userId, reason)
}
// Emails notifications
if (sendToEmail)
await sendMarketResolutionEmail(
reason,
privateUser,
resolutionData.userInvestments[userId] ?? 0,
resolutionData.userPayouts[userId] ?? 0,
creator,
resolutionData.creatorPayout,
contract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
}
const contractFollowersIds = (
await getValues<ContractFollow>(
firestore.collection(`contracts/${contract.id}/follows`)
)
).map((follow) => follow.id)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
await Promise.all(
contractFollowersIds.map((id) =>
sendNotificationsIfSettingsPermit(
id,
resolutionData.userInvestments[id]
? 'resolution_on_contract_with_users_shares_in'
: 'resolution_on_contract_you_follow'
)
)
)
}

View File

@ -21,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { createContractResolvedNotifications } from './create-notification'
import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn' import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn'
import { runTxn, TxnData } from './transact' import { runTxn, TxnData } from './transact'
import { import {
@ -177,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
groupBy(bets, (bet) => bet.userId), groupBy(bets, (bet) => bet.userId),
(bets) => getContractBetMetrics(contract, bets).invested (bets) => getContractBetMetrics(contract, bets).invested
) )
let resolutionText = outcome ?? contract.question
if (
contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'MULTIPLE_CHOICE'
) {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && value) resolutionText = `${value}`
}
// TODO: this actually may be too slow to complete with a ton of users to notify? await createContractResolvedNotifications(
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
creator,
contract.id + '-resolution',
resolutionText,
contract, contract,
undefined, creator,
outcome,
probabilityInt,
value,
{ {
bets, bets,
userInvestments, userInvestments,

View File

@ -4,7 +4,7 @@ import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { Post } from 'common/post' import { Post } from '../../common/post'
export const log = (...args: unknown[]) => { export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args) console.log(`[${new Date().toISOString()}]`, ...args)

View File

@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react'
import Router, { useRouter } from 'next/router' import Router, { useRouter } from 'next/router'
import { import {
BetFillData, BetFillData,
ContractResolutionData,
Notification, Notification,
notification_source_types, notification_source_types,
} from 'common/notification' } from 'common/notification'
@ -706,7 +707,7 @@ function NotificationItem(props: {
isChildOfGroup?: boolean isChildOfGroup?: boolean
}) { }) {
const { notification, justSummary, isChildOfGroup } = props const { notification, justSummary, isChildOfGroup } = props
const { sourceType, reason } = notification const { sourceType, reason, sourceUpdateType } = notification
const [highlighted] = useState(!notification.isSeen) const [highlighted] = useState(!notification.isSeen)
@ -724,6 +725,15 @@ function NotificationItem(props: {
justSummary={justSummary} justSummary={justSummary}
/> />
) )
} else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
return (
<ContractResolvedNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
} }
// TODO Add new notification components here // TODO Add new notification components here
@ -810,7 +820,8 @@ function NotificationFrame(props: {
sourceText, sourceText,
} = notification } = notification
const questionNeedsResolution = sourceUpdateType == 'closed' const questionNeedsResolution = sourceUpdateType == 'closed'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
return ( return (
<div <div
className={clsx( className={clsx(
@ -860,7 +871,7 @@ function NotificationFrame(props: {
name={sourceUserName || ''} name={sourceUserName || ''}
username={sourceUserUsername || ''} username={sourceUserUsername || ''}
className={'relative mr-1 flex-shrink-0'} className={'relative mr-1 flex-shrink-0'}
short={true} short={isMobile}
/> />
{subtitle} {subtitle}
{isChildOfGroup ? ( {isChildOfGroup ? (
@ -945,6 +956,83 @@ function BetFillNotification(props: {
) )
} }
function ContractResolvedNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market'
const resolutionDescription = () => {
if (!sourceText) return <div />
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
const description =
userInvestment && userPayout ? (
<Row className={'gap-1 '}>
{resolutionDescription()}
Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout:
<span
className={clsx(
userPayout > 0 ? 'text-primary' : 'text-red-500',
'truncate'
)}
>
{formatMoney(userPayout)}
{` (${userPayout > 0 ? '+' : '-'}${Math.round(
((userPayout - userInvestment) / userInvestment) * 100
)}%)`}
</span>
</Row>
) : (
<span>{resolutionDescription()}</span>
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>{description}</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>{description}</span>
</Row>
</NotificationFrame>
)
}
export const setNotificationsAsSeen = async (notifications: Notification[]) => { export const setNotificationsAsSeen = async (notifications: Notification[]) => {
const unseenNotifications = notifications.filter((n) => !n.isSeen) const unseenNotifications = notifications.filter((n) => !n.isSeen)
return await Promise.all( return await Promise.all(
@ -1064,30 +1152,7 @@ function NotificationTextLabel(props: {
if (sourceType === 'contract') { if (sourceType === 'contract') {
if (justSummary || !sourceText) return <div /> if (justSummary || !sourceText) return <div />
// Resolved contracts // Resolved contracts
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
{
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return (
<ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
)
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={className ? className : 'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
}
// Close date will be a number - it looks better without it // Close date will be a number - it looks better without it
if (sourceUpdateType === 'closed') { if (sourceUpdateType === 'closed') {
return <div /> return <div />