Enrich contract resolved notification
This commit is contained in:
parent
b903183fff
commit
7628713c4b
|
@ -2,3 +2,8 @@ export type Follow = {
|
|||
userId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type ContractFollow = {
|
||||
id: string // user id
|
||||
createdTime: number
|
||||
}
|
||||
|
|
|
@ -248,3 +248,9 @@ export type BetFillData = {
|
|||
probability: number
|
||||
fillAmount: number
|
||||
}
|
||||
|
||||
export type ContractResolutionData = {
|
||||
outcome: string
|
||||
userPayout: number
|
||||
userInvestment: number
|
||||
}
|
||||
|
|
|
@ -218,6 +218,7 @@ const notificationReasonToSubscriptionType: Partial<
|
|||
|
||||
export const getNotificationDestinationsForUser = (
|
||||
privateUser: PrivateUser,
|
||||
// TODO: accept reasons array from most to least important and work backwards
|
||||
reason: notification_reason_types | notification_preference
|
||||
) => {
|
||||
const notificationSettings = privateUser.notificationPreferences
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as admin from 'firebase-admin'
|
|||
import {
|
||||
BetFillData,
|
||||
BettingStreakData,
|
||||
ContractResolutionData,
|
||||
Notification,
|
||||
notification_reason_types,
|
||||
} from '../../common/notification'
|
||||
|
@ -28,6 +29,7 @@ import {
|
|||
} from './emails'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
import { ContractFollow } from '../../common/follow'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type recipients_to_reason_texts = {
|
||||
|
@ -159,7 +161,7 @@ export type replied_users_info = {
|
|||
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||
sourceId: string,
|
||||
sourceType: 'comment' | 'answer' | 'contract',
|
||||
sourceUpdateType: 'created' | 'updated' | 'resolved',
|
||||
sourceUpdateType: 'created' | 'updated',
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
|
@ -167,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
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 ?? {}
|
||||
|
@ -230,11 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
userId: string,
|
||||
reason: notification_reason_types
|
||||
) => {
|
||||
if (
|
||||
!stillFollowingContract(sourceContract.creatorId) ||
|
||||
sourceUser.id == userId
|
||||
)
|
||||
return
|
||||
if (!stillFollowingContract(userId) || sourceUser.id == userId) return
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||
|
@ -276,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 notifyTaggedUsers()
|
||||
await notifyContractCreator()
|
||||
|
@ -943,3 +914,130 @@ export const createNewContractNotification = async (
|
|||
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'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
|||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { getContractBetMetrics } from '../../common/calculate'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { createContractResolvedNotifications } from './create-notification'
|
||||
import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
import {
|
||||
|
@ -177,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
groupBy(bets, (bet) => bet.userId),
|
||||
(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 createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'resolved',
|
||||
creator,
|
||||
contract.id + '-resolution',
|
||||
resolutionText,
|
||||
await createContractResolvedNotifications(
|
||||
contract,
|
||||
undefined,
|
||||
creator,
|
||||
outcome,
|
||||
probabilityInt,
|
||||
value,
|
||||
{
|
||||
bets,
|
||||
userInvestments,
|
||||
|
|
|
@ -4,7 +4,7 @@ import { chunk } from 'lodash'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { Group } from '../../common/group'
|
||||
import { Post } from 'common/post'
|
||||
import { Post } from '../../common/post'
|
||||
|
||||
export const log = (...args: unknown[]) => {
|
||||
console.log(`[${new Date().toISOString()}]`, ...args)
|
||||
|
|
|
@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|||
import Router, { useRouter } from 'next/router'
|
||||
import {
|
||||
BetFillData,
|
||||
ContractResolutionData,
|
||||
Notification,
|
||||
notification_source_types,
|
||||
} from 'common/notification'
|
||||
|
@ -706,7 +707,7 @@ function NotificationItem(props: {
|
|||
isChildOfGroup?: boolean
|
||||
}) {
|
||||
const { notification, justSummary, isChildOfGroup } = props
|
||||
const { sourceType, reason } = notification
|
||||
const { sourceType, reason, sourceUpdateType } = notification
|
||||
|
||||
const [highlighted] = useState(!notification.isSeen)
|
||||
|
||||
|
@ -724,6 +725,15 @@ function NotificationItem(props: {
|
|||
justSummary={justSummary}
|
||||
/>
|
||||
)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
|
||||
return (
|
||||
<ContractResolvedNotification
|
||||
notification={notification}
|
||||
isChildOfGroup={isChildOfGroup}
|
||||
highlighted={highlighted}
|
||||
justSummary={justSummary}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// TODO Add new notification components here
|
||||
|
||||
|
@ -810,7 +820,8 @@ function NotificationFrame(props: {
|
|||
sourceText,
|
||||
} = notification
|
||||
const questionNeedsResolution = sourceUpdateType == 'closed'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 600
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
@ -860,7 +871,7 @@ function NotificationFrame(props: {
|
|||
name={sourceUserName || ''}
|
||||
username={sourceUserUsername || ''}
|
||||
className={'relative mr-1 flex-shrink-0'}
|
||||
short={true}
|
||||
short={isMobile}
|
||||
/>
|
||||
{subtitle}
|
||||
{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[]) => {
|
||||
const unseenNotifications = notifications.filter((n) => !n.isSeen)
|
||||
return await Promise.all(
|
||||
|
@ -1064,30 +1152,7 @@ function NotificationTextLabel(props: {
|
|||
if (sourceType === 'contract') {
|
||||
if (justSummary || !sourceText) return <div />
|
||||
// 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
|
||||
if (sourceUpdateType === 'closed') {
|
||||
return <div />
|
||||
|
|
Loading…
Reference in New Issue
Block a user