Linked notification settings to notification rules

This commit is contained in:
Ian Philips 2022-09-08 16:59:10 -06:00
parent d88b4ed081
commit 87dd678752
18 changed files with 761 additions and 486 deletions

View File

@ -1,3 +1,5 @@
import { exhaustive_notification_subscribe_types } from 'common/user'
export type Notification = { export type Notification = {
id: string id: string
userId: string userId: string
@ -53,26 +55,91 @@ export type notification_source_update_types =
export type notification_reason_types = export type notification_reason_types =
| 'tagged_user' | 'tagged_user'
| 'on_users_contract' // | 'on_users_contract'
| 'on_contract_with_users_shares_in' // | 'on_contract_with_users_shares_in'
| 'on_contract_with_users_shares_out' // | 'on_contract_with_users_shares_out'
| 'on_contract_with_users_answer' // | 'on_contract_with_users_answer'
| 'on_contract_with_users_comment' // | 'on_contract_with_users_comment'
| 'reply_to_users_answer'
| 'reply_to_users_comment'
| 'on_new_follow' | 'on_new_follow'
| 'you_follow_user' | 'contract_from_followed_user'
| 'added_you_to_group' | 'added_you_to_group'
| 'you_referred_user' | 'you_referred_user'
| 'user_joined_to_bet_on_your_market' | 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract' | 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of' // | 'on_group_you_are_member_of'
| 'tip_received' | 'tip_received'
| 'bet_fill' | 'bet_fill'
| 'user_joined_from_your_group_invite' | 'user_joined_from_your_group_invite'
| 'challenge_accepted' | 'challenge_accepted'
| 'betting_streak_incremented' | 'betting_streak_incremented'
| 'loan_income' | 'loan_income'
| 'you_follow_contract' // | 'you_follow_contract'
| 'liked_your_contract' | 'liked_your_contract'
| 'liked_and_tipped_your_contract' | 'liked_and_tipped_your_contract'
| 'comment_on_your_contract'
| 'answer_on_your_contract'
| 'comment_on_contract_you_follow'
| 'answer_on_contract_you_follow'
| 'update_on_contract_you_follow'
| 'resolution_on_contract_you_follow'
| 'comment_on_contract_with_users_shares_in'
| 'answer_on_contract_with_users_shares_in'
| 'update_on_contract_with_users_shares_in'
| 'resolution_on_contract_with_users_shares_in'
| 'comment_on_contract_with_users_answer'
| 'update_on_contract_with_users_answer'
| 'resolution_on_contract_with_users_answer'
| 'answer_on_contract_with_users_answer'
| 'comment_on_contract_with_users_comment'
| 'answer_on_contract_with_users_comment'
| 'update_on_contract_with_users_comment'
| 'resolution_on_contract_with_users_comment'
| 'reply_to_users_answer'
| 'reply_to_users_comment'
| 'your_contract_closed'
export const notificationReasonToSubscribeTypeMap: Record<
notification_reason_types,
keyof exhaustive_notification_subscribe_types
> = {
tagged_user: 'user_tagged_you',
on_new_follow: 'new_followers',
contract_from_followed_user: 'new_markets_by_followed_users',
added_you_to_group: 'group_adds',
you_referred_user: 'referral_bonuses',
user_joined_to_bet_on_your_market: 'referral_bonuses',
unique_bettors_on_your_contract: 'unique_bettor_bonuses',
tip_received: 'tips_on_your_comments',
bet_fill: 'limit_order_fills',
user_joined_from_your_group_invite: 'referral_bonuses',
challenge_accepted: 'limit_order_fills',
betting_streak_incremented: 'betting_streaks',
loan_income: 'loan_income',
liked_your_contract: 'tips_on_your_markets',
liked_and_tipped_your_contract: 'tips_on_your_markets',
comment_on_your_contract: 'all_comments_on_my_markets',
answer_on_your_contract: 'all_answers_on_my_markets',
comment_on_contract_you_follow: 'all_comments_on_watched_markets',
answer_on_contract_you_follow: 'all_answers_on_watched_markets',
update_on_contract_you_follow: 'market_updates_on_watched_markets',
resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
comment_on_contract_with_users_shares_in:
'all_comments_on_contracts_with_shares_in_on_watched_markets',
answer_on_contract_with_users_shares_in:
'all_answers_on_contracts_with_shares_in_on_watched_markets',
update_on_contract_with_users_shares_in:
'market_updates_with_shares_in_on_watched_markets',
resolution_on_contract_with_users_shares_in:
'resolutions_on_watched_markets_with_shares_in',
comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
your_contract_closed: 'my_markets_closed',
}

View File

@ -1,3 +1,5 @@
import { filterDefined } from './util/array'
export type User = { export type User = {
id: string id: string
createdTime: number createdTime: number
@ -67,44 +69,53 @@ export type PrivateUser = {
notificationSubscriptionTypes: exhaustive_notification_subscribe_types notificationSubscriptionTypes: exhaustive_notification_subscribe_types
} }
export type notification_receive_types = 'email' | 'browser' export type notification_destination_types = 'email' | 'browser'
export type exhaustive_notification_subscribe_types = { export type exhaustive_notification_subscribe_types = {
// Watched Markets // Watched Markets
all_comments: notification_receive_types[] // Email currently - seems bad all_comments_on_watched_markets: notification_destination_types[] // Email currently - seems bad
all_answers: notification_receive_types[] // Email currently - seems bad all_answers_on_watched_markets: notification_destination_types[] // Email currently - seems bad
// Comments // Comments
tipped_comments: notification_receive_types[] // Email tipped_comments_on_watched_markets: notification_destination_types[] // Email
comments_by_followed_users: notification_receive_types[] comments_by_followed_users_on_watched_markets: notification_destination_types[]
all_replies_to_my_comments: notification_receive_types[] // Email all_replies_to_my_comments_on_watched_markets: notification_destination_types[] // Email
all_replies_to_my_answers: notification_receive_types[] // Email all_replies_to_my_answers_on_watched_markets: notification_destination_types[] // Email
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// Answers // Answers
answers_by_followed_users: notification_receive_types[] answers_by_followed_users_on_watched_markets: notification_destination_types[]
answers_by_market_creator: notification_receive_types[] answers_by_market_creator_on_watched_markets: notification_destination_types[]
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// On users' markets // On users' markets
my_markets_closed: notification_receive_types[] // Email, Recommended my_markets_closed: notification_destination_types[] // Email, Recommended
all_comments_on_my_markets: notification_receive_types[] // Email all_comments_on_my_markets: notification_destination_types[] // Email
all_answers_on_my_markets: notification_receive_types[] // Email all_answers_on_my_markets: notification_destination_types[] // Email
// Market updates // Market updates
resolutions: notification_receive_types[] // Email resolutions_on_watched_markets: notification_destination_types[] // Email
market_updates: notification_receive_types[] resolutions_on_watched_markets_with_shares_in: notification_destination_types[] // Email
probability_updates: notification_receive_types[] // Email - would want persistent changes only though market_updates_on_watched_markets: notification_destination_types[]
market_updates_with_shares_in_on_watched_markets: notification_destination_types[]
probability_updates_on_watched_markets: notification_destination_types[] // Email - would want persistent changes only though
// Balance Changes // Balance Changes
loans: notification_receive_types[] loan_income: notification_destination_types[]
betting_streaks: notification_receive_types[] betting_streaks: notification_destination_types[]
referral_bonuses: notification_receive_types[] referral_bonuses: notification_destination_types[]
unique_bettor_bonuses: notification_receive_types[] unique_bettor_bonuses: notification_destination_types[]
tips_on_your_comments: notification_destination_types[]
tips_on_your_markets: notification_destination_types[]
limit_order_fills: notification_destination_types[]
// General // General
user_tagged_you: notification_receive_types[] // Email user_tagged_you: notification_destination_types[] // Email
new_markets_by_followed_users: notification_receive_types[] // Email new_followers: notification_destination_types[] // Email
trending_markets: notification_receive_types[] // Email group_adds: notification_destination_types[] // Email
profit_loss_updates: notification_receive_types[] // Email new_markets_by_followed_users: notification_destination_types[] // Email
trending_markets: notification_destination_types[] // Email
profit_loss_updates: notification_destination_types[] // Email
} }
export type notification_subscribe_types = 'all' | 'less' | 'none' export type notification_subscribe_types = 'all' | 'less' | 'none'
@ -118,3 +129,131 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const getDefaultNotificationSettings = (
userId: string,
privateUser?: PrivateUser,
noEmails?: boolean
) => {
const prevPref = privateUser?.notificationPreferences ?? 'all'
const wantsLess = prevPref === 'less'
const wantsAll = prevPref === 'all'
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// Comments
tips_on_your_comments: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
comments_by_followed_users_on_watched_markets: constructPref(
wantsAll,
false
), //wantsAll ? browserOnly : none,
all_replies_to_my_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
), //wantsAll || wantsLess ? both : none,
all_replies_to_my_answers_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
), //wantsAll || wantsLess ? both : none,
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
), //wantsAll || wantsLess ? both : none,
answers_by_market_creator_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
), //wantsAll || wantsLess ? both : none,
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// On users' markets
my_markets_closed: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
), //wantsAll || wantsLess ? both : none, // High priority
all_comments_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
), //wantsAll || wantsLess ? both : none,
all_answers_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
), //wantsAll || wantsLess ? both : none,
// Market updates
resolutions_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
market_updates_with_shares_in_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
//Balance Changes
loan_income: constructPref(wantsAll || wantsLess, false),
betting_streaks: constructPref(wantsAll || wantsLess, false),
referral_bonuses: constructPref(wantsAll || wantsLess, true),
unique_bettor_bonuses: constructPref(wantsAll || wantsLess, false),
tipped_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(wantsAll || wantsLess, true),
limit_order_fills: constructPref(wantsAll || wantsLess, false),
// General
user_tagged_you: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
new_followers: constructPref(wantsAll || wantsLess, true),
new_markets_by_followed_users: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
group_adds: constructPref(wantsAll || wantsLess, true),
} as exhaustive_notification_subscribe_types
}

View File

@ -2,10 +2,30 @@
"functions": { "functions": {
"predeploy": "cd functions && yarn build", "predeploy": "cd functions && yarn build",
"runtime": "nodejs16", "runtime": "nodejs16",
"source": "functions/dist" "source": "functions/dist",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log"
]
}, },
"firestore": { "firestore": {
"rules": "firestore.rules", "rules": "firestore.rules",
"indexes": "firestore.indexes.json" "indexes": "firestore.indexes.json"
},
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"pubsub": {
"port": 8085
},
"ui": {
"enabled": true
}
} }
} }

View File

@ -159,7 +159,7 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isSeen', 'viewTime']); .hasOnly(['isSeen', 'viewTime']);
} }
match /{somePath=**}/groupMembers/{memberId} { match /{somePath=**}/groupMembers/{memberId} {
allow read; allow read;
} }
@ -168,7 +168,7 @@ service cloud.firestore {
allow read; allow read;
} }
match /groups/{groupId} { match /groups/{groupId} {
allow read; allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
@ -182,7 +182,7 @@ service cloud.firestore {
match /groupMembers/{memberId}{ match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId; allow delete: if request.auth.uid == resource.data.userId;
} }
function isGroupMember() { function isGroupMember() {

View File

@ -17,4 +17,5 @@ package-lock.json
ui-debug.log ui-debug.log
firebase-debug.log firebase-debug.log
firestore-debug.log firestore-debug.log
pubsub-debug.log
firestore_export/ firestore_export/

View File

@ -5,8 +5,7 @@ import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { getNewMultiBetInfo } from '../../common/new-bet' import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils' import { getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
const bodySchema = z.object({ const bodySchema = z.object({
@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer return answer
}) })
const contract = await getContract(contractId)
if (answer && contract) await sendNewAnswerEmail(answer, contract)
return answer return answer
}) })

View File

@ -10,7 +10,7 @@ import {
MAX_GROUP_NAME_LENGTH, MAX_GROUP_NAME_LENGTH,
MAX_ID_LENGTH, MAX_ID_LENGTH,
} from '../../common/group' } from '../../common/group'
import { APIError, newEndpoint, validate } from '../../functions/src/api' import { APIError, newEndpoint, validate } from './api'
import { z } from 'zod' import { z } from 'zod'
const bodySchema = z.object({ const bodySchema = z.object({

View File

@ -4,10 +4,11 @@ import {
notification_reason_types, notification_reason_types,
notification_source_update_types, notification_source_update_types,
notification_source_types, notification_source_types,
notificationReasonToSubscribeTypeMap,
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getValues, log } from './utils' import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
@ -15,13 +16,17 @@ import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn' import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Group } from '../../common/group'
import { Challenge } from '../../common/challenge' import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse'
import { Like } from '../../common/like' import { Like } from '../../common/like'
import {
sendMarketResolutionEmail,
sendNewAnswerEmail,
sendNewCommentEmail,
} from './emails'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { reason: notification_reason_types }
} }
@ -43,7 +48,7 @@ export const createNotification = async (
const shouldGetNotification = ( const shouldGetNotification = (
userId: string, userId: string,
userToReasonTexts: user_to_reason_texts userToReasonTexts: recipients_to_reason_texts
) => { ) => {
return ( return (
sourceUser.id != userId && sourceUser.id != userId &&
@ -52,17 +57,21 @@ export const createNotification = async (
} }
const createUsersNotifications = async ( const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts userToReasonTexts: recipients_to_reason_texts
) => { ) => {
await Promise.all( await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => { Object.keys(userToReasonTexts).map(async (userId) => {
const { reason } = userToReasonTexts[userId]
const { sendToBrowser } = await getDestinationsForUser(userId, reason)
if (!sendToBrowser) return Promise.resolve()
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${userId}/notifications`) .collection(`/users/${userId}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
const notification: Notification = { const notification: Notification = {
id: idempotencyKey, id: idempotencyKey,
userId, userId,
reason: userToReasonTexts[userId].reason, reason,
createdTime: Date.now(), createdTime: Date.now(),
isSeen: false, isSeen: false,
sourceId, sourceId,
@ -85,7 +94,7 @@ export const createNotification = async (
} }
const notifyUsersFollowers = async ( const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts userToReasonTexts: recipients_to_reason_texts
) => { ) => {
const followers = await firestore const followers = await firestore
.collectionGroup('follows') .collectionGroup('follows')
@ -99,14 +108,14 @@ export const createNotification = async (
shouldGetNotification(followerUserId, userToReasonTexts) shouldGetNotification(followerUserId, userToReasonTexts)
) { ) {
userToReasonTexts[followerUserId] = { userToReasonTexts[followerUserId] = {
reason: 'you_follow_user', reason: 'contract_from_followed_user',
} }
} }
}) })
} }
const notifyFollowedUser = ( const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: recipients_to_reason_texts,
followedUserId: string followedUserId: string
) => { ) => {
if (shouldGetNotification(followedUserId, userToReasonTexts)) if (shouldGetNotification(followedUserId, userToReasonTexts))
@ -116,7 +125,7 @@ export const createNotification = async (
} }
const notifyTaggedUsers = ( const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: recipients_to_reason_texts,
userIds: (string | undefined)[] userIds: (string | undefined)[]
) => { ) => {
userIds.forEach((id) => { userIds.forEach((id) => {
@ -128,7 +137,7 @@ export const createNotification = async (
} }
const notifyContractCreator = async ( const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: recipients_to_reason_texts,
sourceContract: Contract, sourceContract: Contract,
options?: { force: boolean } options?: { force: boolean }
) => { ) => {
@ -137,12 +146,12 @@ export const createNotification = async (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) shouldGetNotification(sourceContract.creatorId, userToReasonTexts)
) )
userToReasonTexts[sourceContract.creatorId] = { userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract', reason: 'your_contract_closed',
} }
} }
const notifyUserAddedToGroup = ( const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: recipients_to_reason_texts,
relatedUserId: string relatedUserId: string
) => { ) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts)) if (shouldGetNotification(relatedUserId, userToReasonTexts))
@ -151,7 +160,7 @@ export const createNotification = async (
} }
} }
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: recipients_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) { if (sourceType === 'follow' && recipients?.[0]) {
@ -188,54 +197,63 @@ export const createNotification = async (
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
} }
const getDestinationsForUser = async (
userId: string,
reason: notification_reason_types
) => {
const privateUser = await getPrivateUser(userId)
if (!privateUser) return { sendToEmail: false, sendToBrowser: false }
const notificationSettings = privateUser.notificationSubscriptionTypes
console.log('notificationSettings', notificationSettings)
console.log('reason', reason)
console.log('notif reason to type map', notificationReasonToSubscribeTypeMap)
const subscribeType = notificationReasonToSubscribeTypeMap[reason]
console.log('subscribeType', subscribeType)
const destinations =
notificationSettings[notificationReasonToSubscribeTypeMap[reason]]
console.log('destinations', destinations)
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
}
}
export const createCommentOrAnswerOrUpdatedContractNotification = async ( export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string, sourceId: string,
sourceType: notification_source_types, sourceType: 'comment' | 'answer' | 'contract',
sourceUpdateType: notification_source_update_types, sourceUpdateType: 'created' | 'updated' | 'resolved',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
sourceContract: Contract, sourceContract: Contract,
miscData?: { miscData?: {
relatedSourceType?: notification_source_types repliedToType?: 'comment' | 'answer'
repliedToId?: string
repliedToContent?: string
repliedUserId?: string repliedUserId?: string
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 { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} const {
repliedToType,
repliedToContent,
repliedUserId,
taggedUserIds,
repliedToId,
} = miscData ?? {}
const createUsersNotifications = async ( const recipientIdsList: string[] = []
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[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,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
// get contract follower documents and check here if they're a follower // get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore const contractFollowersSnap = await firestore
@ -244,48 +262,128 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
const contractFollowersIds = contractFollowersSnap.docs.map( const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id (doc) => doc.data().id
) )
log('contractFollowerIds', contractFollowersIds)
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) => { const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId) return contractFollowersIds.includes(userId)
} }
const shouldGetNotification = ( const sendNotificationsIfSettingsPermit = async (
userId: string, userId: string,
userToReasonTexts: user_to_reason_texts reason: notification_reason_types
) => { ) => {
return ( if (
sourceUser.id != userId && !stillFollowingContract(sourceContract.creatorId) ||
!Object.keys(userToReasonTexts).includes(userId) sourceUser.id == userId ||
recipientIdsList.includes(userId)
) )
} return
const notifyContractFollowers = async ( const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
userToReasonTexts: user_to_reason_texts userId,
) => { reason
for (const userId of contractFollowersIds) { )
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = { if (sendToBrowser) {
reason: 'you_follow_contract', await createBrowserNotification(userId, reason)
} recipientIdsList.push(userId)
}
if (sendToEmail) {
if (sourceType === 'comment') {
// if the source contract is a free response contract, send the email
await sendNewCommentEmail(
userId,
sourceUser,
sourceContract,
sourceText,
sourceId,
// TODO: Add any paired bets to the comment
undefined,
repliedToType === 'answer' ? repliedToContent : undefined,
repliedToType === 'answer' ? repliedToId : undefined
)
} else if (sourceType === 'answer')
await sendNewAnswerEmail(
userId,
sourceUser.name,
sourceText,
sourceContract,
sourceUser.avatarUrl
)
else if (
sourceType === 'contract' &&
sourceUpdateType === 'resolved' &&
resolutionData
)
await sendMarketResolutionEmail(
userId,
resolutionData.userInvestments[userId],
resolutionData.userPayouts[userId],
sourceUser,
resolutionData.creatorPayout,
sourceContract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
recipientIdsList.push(userId)
} }
} }
const notifyContractCreator = async ( const notifyContractFollowers = async () => {
userToReasonTexts: user_to_reason_texts for (const userId of contractFollowersIds) {
) => { await sendNotificationsIfSettingsPermit(
if ( userId,
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && sourceType === 'answer'
stillFollowingContract(sourceContract.creatorId) ? 'answer_on_contract_you_follow'
) : sourceType === 'comment'
userToReasonTexts[sourceContract.creatorId] = { ? 'comment_on_contract_you_follow'
reason: 'on_users_contract', : sourceUpdateType === 'updated'
} ? 'update_on_contract_you_follow'
: 'resolution_on_contract_you_follow'
)
}
} }
const notifyOtherAnswerersOnContract = async ( const notifyContractCreator = async () => {
userToReasonTexts: user_to_reason_texts await sendNotificationsIfSettingsPermit(
) => { sourceContract.creatorId,
sourceType === 'comment'
? 'comment_on_your_contract'
: 'answer_on_your_contract'
)
}
const notifyOtherAnswerersOnContract = async () => {
const answers = await getValues<Answer>( const answers = await getValues<Answer>(
firestore firestore
.collection('contracts') .collection('contracts')
@ -293,20 +391,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
.collection('answers') .collection('answers')
) )
const recipientUserIds = uniq(answers.map((answer) => answer.userId)) const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => { await Promise.all(
if ( recipientUserIds.map((userId) =>
shouldGetNotification(userId, userToReasonTexts) && sendNotificationsIfSettingsPermit(
stillFollowingContract(userId) 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'
)
) )
userToReasonTexts[userId] = { )
reason: 'on_contract_with_users_answer',
}
})
} }
const notifyOtherCommentersOnContract = async ( const notifyOtherCommentersOnContract = async () => {
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>( const comments = await getValues<Comment>(
firestore firestore
.collection('contracts') .collection('contracts')
@ -314,20 +415,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
.collection('comments') .collection('comments')
) )
const recipientUserIds = uniq(comments.map((comment) => comment.userId)) const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => { await Promise.all(
if ( recipientUserIds.map((userId) =>
shouldGetNotification(userId, userToReasonTexts) && sendNotificationsIfSettingsPermit(
stillFollowingContract(userId) 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'
)
) )
userToReasonTexts[userId] = { )
reason: 'on_contract_with_users_comment',
}
})
} }
const notifyBettorsOnContract = async ( const notifyBettorsOnContract = async () => {
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`) .collection(`contracts/${sourceContract.id}/bets`)
.get() .get()
@ -343,88 +447,77 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
) )
} }
) )
recipientUserIds.forEach((userId) => { await Promise.all(
if ( recipientUserIds.map((userId) =>
shouldGetNotification(userId, userToReasonTexts) && sendNotificationsIfSettingsPermit(
stillFollowingContract(userId) 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'
)
) )
userToReasonTexts[userId] = { )
reason: 'on_contract_with_users_shares_in',
}
})
} }
const notifyRepliedUser = ( const notifyRepliedUser = async (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string, relatedUserId: string,
relatedSourceType: notification_source_types relatedSourceType: notification_source_types
) => { ) => {
if ( await sendNotificationsIfSettingsPermit(
shouldGetNotification(relatedUserId, userToReasonTexts) && relatedUserId,
stillFollowingContract(relatedUserId) relatedSourceType === 'answer'
) { ? 'reply_to_users_answer'
if (relatedSourceType === 'comment') { : 'reply_to_users_comment'
userToReasonTexts[relatedUserId] = { )
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
} }
const notifyTaggedUsers = ( const notifyTaggedUsers = async (userIds: string[]) => {
userToReasonTexts: user_to_reason_texts, await Promise.all(
userIds: (string | undefined)[] userIds.map((userId) =>
) => { sendNotificationsIfSettingsPermit(userId, 'tagged_user')
userIds.forEach((id) => { )
console.log('tagged user: ', id) )
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
} }
const notifyLiquidityProviders = async ( const notifyLiquidityProviders = async () => {
userToReasonTexts: user_to_reason_texts
) => {
const liquidityProviders = await firestore const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`) .collection(`contracts/${sourceContract.id}/liquidity`)
.get() .get()
const liquidityProvidersIds = uniq( const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId) liquidityProviders.docs.map((doc) => doc.data().userId)
) )
liquidityProvidersIds.forEach((userId) => { await Promise.all(
if ( liquidityProvidersIds.map((userId) =>
shouldGetNotification(userId, userToReasonTexts) && sendNotificationsIfSettingsPermit(
stillFollowingContract(userId) userId,
) { sourceType === 'answer'
userToReasonTexts[userId] = { ? 'answer_on_contract_with_users_shares_in'
reason: '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 userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') { if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType) if (repliedUserId && repliedToType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) await notifyRepliedUser(repliedUserId, repliedToType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) await notifyTaggedUsers(taggedUserIds ?? [])
} }
await notifyContractCreator(userToReasonTexts) await notifyContractCreator()
await notifyOtherAnswerersOnContract(userToReasonTexts) await notifyOtherAnswerersOnContract()
await notifyLiquidityProviders(userToReasonTexts) await notifyLiquidityProviders()
await notifyBettorsOnContract(userToReasonTexts) await notifyBettorsOnContract()
await notifyOtherCommentersOnContract(userToReasonTexts) await notifyOtherCommentersOnContract()
// if they weren't added previously, add them now // if they weren't notified previously, notify them now
await notifyContractFollowers(userToReasonTexts) await notifyContractFollowers()
await createUsersNotifications(userToReasonTexts)
} }
export const createTipNotification = async ( export const createTipNotification = async (
@ -436,8 +529,13 @@ export const createTipNotification = async (
contract?: Contract, contract?: Contract,
group?: Group group?: Group
) => { ) => {
const slug = group ? group.slug + `#${commentId}` : commentId const { sendToBrowser } = await getDestinationsForUser(
toUser.id,
'tip_received'
)
if (!sendToBrowser) return
const slug = group ? group.slug + `#${commentId}` : commentId
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`) .collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
@ -471,6 +569,9 @@ export const createBetFillNotification = async (
contract: Contract, contract: Contract,
idempotencyKey: string idempotencyKey: string
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(toUser.id, 'bet_fill')
if (!sendToBrowser) return
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0 const fillAmount = fill?.amount ?? 0
@ -498,38 +599,6 @@ export const createBetFillNotification = async (
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
export const createGroupCommentNotification = async (
fromUser: User,
toUserId: string,
comment: Comment,
group: Group,
idempotencyKey: string
) => {
if (toUserId === fromUser.id) return
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}`
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'on_group_you_are_member_of',
createdTime: Date.now(),
isSeen: false,
sourceId: comment.id,
sourceType: 'comment',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: richTextToString(comment.content),
sourceSlug,
sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug,
}
await notificationRef.set(removeUndefinedProps(notification))
}
export const createReferralNotification = async ( export const createReferralNotification = async (
toUser: User, toUser: User,
referredUser: User, referredUser: User,
@ -538,6 +607,12 @@ export const createReferralNotification = async (
referredByContract?: Contract, referredByContract?: Contract,
referredByGroup?: Group referredByGroup?: Group
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(
toUser.id,
'you_referred_user'
)
if (!sendToBrowser) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`) .collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
@ -582,6 +657,12 @@ export const createLoanIncomeNotification = async (
idempotencyKey: string, idempotencyKey: string,
income: number income: number
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(
toUser.id,
'loan_income'
)
if (!sendToBrowser) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`) .collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
@ -612,6 +693,12 @@ export const createChallengeAcceptedNotification = async (
acceptedAmount: number, acceptedAmount: number,
contract: Contract contract: Contract
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(
challengeCreator.id,
'challenge_accepted'
)
if (!sendToBrowser) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`) .collection(`/users/${challengeCreator.id}/notifications`)
.doc() .doc()
@ -645,6 +732,12 @@ export const createBettingStreakBonusNotification = async (
amount: number, amount: number,
idempotencyKey: string idempotencyKey: string
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(
user.id,
'betting_streak_incremented'
)
if (!sendToBrowser) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${user.id}/notifications`) .collection(`/users/${user.id}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
@ -680,6 +773,12 @@ export const createLikeNotification = async (
contract: Contract, contract: Contract,
tip?: TipTxn tip?: TipTxn
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(
toUser.id,
'liked_and_tipped_your_contract'
)
if (!sendToBrowser) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`) .collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)
@ -727,6 +826,12 @@ export const createUniqueBettorBonusNotification = async (
amount: number, amount: number,
idempotencyKey: string idempotencyKey: string
) => { ) => {
const { sendToBrowser } = await getDestinationsForUser(
contractCreatorId,
'unique_bettors_on_your_contract'
)
if (!sendToBrowser) return
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`) .collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey) .doc(idempotencyKey)

View File

@ -1,7 +1,11 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { PrivateUser, User } from '../../common/user' import {
getDefaultNotificationSettings,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils' import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { import {
@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
email, email,
initialIpAddress: req.ip, initialIpAddress: req.ip,
initialDeviceToken: deviceToken, initialDeviceToken: deviceToken,
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid),
} }
await firestore.collection('private-users').doc(auth.uid).create(privateUser) await firestore.collection('private-users').doc(auth.uid).create(privateUser)

View File

@ -1,8 +1,6 @@
import { DOMAIN } from '../../common/envs/constants' import { DOMAIN } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { import {
@ -18,6 +16,7 @@ import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api' import { getFunctionUrl } from '../../common/api'
import { richTextToString } from '../../common/util/parse' import { richTextToString } from '../../common/util/parse'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { JSONContent } from '@tiptap/core'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
@ -346,7 +345,8 @@ export const sendNewCommentEmail = async (
userId: string, userId: string,
commentCreator: User, commentCreator: User,
contract: Contract, contract: Contract,
comment: Comment, commentContent: JSONContent | string,
commentId: string,
bet?: Bet, bet?: Bet,
answerText?: string, answerText?: string,
answerId?: string answerId?: string
@ -359,14 +359,17 @@ export const sendNewCommentEmail = async (
) )
return return
const { question, creatorUsername, slug } = contract const { question } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}`
const emailType = 'market-comment' const emailType = 'market-comment'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { content } = comment
const text = richTextToString(content) const text =
typeof commentContent !== 'string'
? richTextToString(commentContent)
: commentContent
let betDescription = '' let betDescription = ''
if (bet) { if (bet) {
@ -380,7 +383,7 @@ export const sendNewCommentEmail = async (
const from = `${commentorName} on Manifold <no-reply@manifold.markets>` const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}` const answerNumber = answerId ? `#${answerId}` : ''
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -423,14 +426,15 @@ export const sendNewCommentEmail = async (
} }
export const sendNewAnswerEmail = async ( export const sendNewAnswerEmail = async (
answer: Answer, userId: string,
contract: Contract name: string,
text: string,
contract: Contract,
avatarUrl?: string
) => { ) => {
// Send to just the creator for now. const { creatorId } = contract
const { creatorId: userId } = contract
// Don't send the creator's own answers. // Don't send the creator's own answers.
if (answer.userId === userId) return if (userId === creatorId) return
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if ( if (
@ -441,7 +445,6 @@ export const sendNewAnswerEmail = async (
return return
const { question, creatorUsername, slug } = contract const { question, creatorUsername, slug } = contract
const { name, avatarUrl, text } = answer
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
const emailType = 'market-answer' const emailType = 'market-answer'

View File

@ -1,15 +1,11 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { compact, uniq } from 'lodash' import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils' import { getContract, getUser, getValues } from './utils'
import { ContractComment } from '../../common/comment' import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
createCommentOrAnswerOrUpdatedContractNotification,
filterUserIdsForOnlyFollowerIds,
} from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions
const comments = await getValues<ContractComment>( const comments = await getValues<ContractComment>(
firestore.collection('contracts').doc(contractId).collection('comments') firestore.collection('contracts').doc(contractId).collection('comments')
) )
const relatedSourceType = comment.replyToCommentId const repliedToType = answer
? 'comment'
: comment.answerOutcome
? 'answer' ? 'answer'
: comment.replyToCommentId
? 'comment'
: undefined : undefined
const repliedUserId = comment.replyToCommentId const repliedUserId = comment.replyToCommentId
@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions
richTextToString(comment.content), richTextToString(comment.content),
contract, contract,
{ {
relatedSourceType, repliedToType,
repliedToId: comment.replyToCommentId || answer?.id,
repliedToContent: answer ? answer.text : undefined,
repliedUserId, repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)), taggedUserIds: compact(parseMentions(comment.content)),
} }
) )
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
uniq([
contract.creatorId,
...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId),
contractId
)
await Promise.all(
recipientUserIds.map((userId) =>
sendNewCommentEmail(
userId,
commentCreator,
contract,
comment,
bet,
answer?.text,
answer?.id
)
)
)
}) })

View File

@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore
if (!contractUpdater) throw new Error('Could not find contract updater') if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract const previousValue = change.before.data() as Contract
if (previousValue.isResolved !== contract.isResolved) { if (
let resolutionText = contract.resolution ?? contract.question
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = contract.answers.find(
(answer) => answer.id === contract.resolution
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && contract.resolutionProbability)
resolutionText = `${contract.resolutionProbability}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && contract.resolutionValue)
resolutionText = `${contract.resolutionValue}`
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
contractUpdater,
eventId,
resolutionText,
contract
)
} else if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question previousValue.question !== contract.question
) { ) {

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { difference, mapValues, groupBy, sumBy } from 'lodash' import { mapValues, groupBy, sumBy } from 'lodash'
import { import {
Contract, Contract,
@ -8,10 +8,8 @@ import {
MultipleChoiceContract, MultipleChoiceContract,
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils' import { getUser, isProd, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails'
import { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
@ -23,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 { floatingEqual } from '../../common/util/math' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -163,15 +161,44 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails( const userInvestments = mapValues(
bets, groupBy(bets, (bet) => bet.userId),
userPayoutsWithoutLoans, (bets) => getContractBetMetrics(contract, bets).invested
)
let resolutionText = contract.resolution ?? contract.question
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = contract.answers.find(
(answer) => answer.id === contract.resolution
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && contract.resolutionProbability)
resolutionText = `${contract.resolutionProbability}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && contract.resolutionValue)
resolutionText = `${contract.resolutionValue}`
}
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
creator, creator,
creatorPayout, contract.id + '-resolution',
resolutionText,
contract, contract,
outcome, undefined,
resolutionProbability, {
resolutions bets,
userInvestments,
userPayouts: userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions,
}
) )
return updatedContract return updatedContract
@ -188,51 +215,51 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
.catch((e) => ({ status: 'error', message: e })) .catch((e) => ({ status: 'error', message: e }))
.then(() => ({ status: 'success' })) .then(() => ({ status: 'success' }))
} }
//
const sendResolutionEmails = async ( // const sendResolutionEmails = async (
bets: Bet[], // bets: Bet[],
userPayouts: { [userId: string]: number }, // userPayouts: { [userId: string]: number },
creator: User, // creator: User,
creatorPayout: number, // creatorPayout: number,
contract: Contract, // contract: Contract,
outcome: string, // outcome: string,
resolutionProbability?: number, // resolutionProbability?: number,
resolutions?: { [outcome: string]: number } // resolutions?: { [outcome: string]: number }
) => { // ) => {
const investedByUser = mapValues( // const investedByUser = mapValues(
groupBy(bets, (bet) => bet.userId), // groupBy(bets, (bet) => bet.userId),
(bets) => getContractBetMetrics(contract, bets).invested // (bets) => getContractBetMetrics(contract, bets).invested
) // )
const investedUsers = Object.keys(investedByUser).filter( // const investedUsers = Object.keys(investedByUser).filter(
(userId) => !floatingEqual(investedByUser[userId], 0) // (userId) => !floatingEqual(investedByUser[userId], 0)
) // )
//
const nonWinners = difference(investedUsers, Object.keys(userPayouts)) // const nonWinners = difference(investedUsers, Object.keys(userPayouts))
const emailPayouts = [ // const emailPayouts = [
...Object.entries(userPayouts), // ...Object.entries(userPayouts),
...nonWinners.map((userId) => [userId, 0] as const), // ...nonWinners.map((userId) => [userId, 0] as const),
].map(([userId, payout]) => ({ // ].map(([userId, payout]) => ({
userId, // userId,
investment: investedByUser[userId] ?? 0, // investment: investedByUser[userId] ?? 0,
payout, // payout,
})) // }))
//
await Promise.all( // await Promise.all(
emailPayouts.map(({ userId, investment, payout }) => // emailPayouts.map(({ userId, investment, payout }) =>
sendMarketResolutionEmail( // sendMarketResolutionEmail(
userId, // userId,
investment, // investment,
payout, // payout,
creator, // creator,
creatorPayout, // creatorPayout,
contract, // contract,
outcome, // outcome,
resolutionProbability, // resolutionProbability,
resolutions // resolutions
) // )
) // )
) // )
} // }
function getResolutionParams(contract: Contract, body: string) { function getResolutionParams(contract: Contract, body: string) {
const { outcomeType } = contract const { outcomeType } = contract

View File

@ -0,0 +1,30 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getDefaultNotificationSettings } from 'common/user'
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
const disableEmails = !isProd()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationSubscriptionTypes: getDefaultNotificationSettings(
privateUser.id,
privateUser,
disableEmails
),
})
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
import { PrivateUser, User } from 'common/user' import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
import { STARTING_BALANCE } from 'common/economy' import { STARTING_BALANCE } from 'common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,6 +21,7 @@ async function main() {
id: user.id, id: user.id,
email, email,
username, username,
notificationSubscriptionTypes: getDefaultNotificationSettings(user.id),
} }
if (user.totalDeposits === undefined) { if (user.totalDeposits === undefined) {

View File

@ -5,7 +5,7 @@ import { Row } from 'web/components/layout/row'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
exhaustive_notification_subscribe_types, exhaustive_notification_subscribe_types,
notification_receive_types, notification_destination_types,
} from 'common/user' } from 'common/user'
import { updatePrivateUser } from 'web/lib/firebase/users' import { updatePrivateUser } from 'web/lib/firebase/users'
import { Switch } from '@headlessui/react' import { Switch } from '@headlessui/react'
@ -28,79 +28,6 @@ import toast from 'react-hot-toast'
export function NotificationSettings() { export function NotificationSettings() {
const privateUser = usePrivateUser() const privateUser = usePrivateUser()
const [showWatchModal, setShowWatchModal] = useState(false) const [showWatchModal, setShowWatchModal] = useState(false)
const prevPref = privateUser?.notificationPreferences
const browserOnly = ['browser']
const emailOnly = ['email']
const both = ['email', 'browser']
const wantsLess = prevPref === 'less'
const wantsAll = prevPref === 'all'
const constructPref = (browserIf: boolean, emailIf: boolean | undefined) => {
const browser = browserIf ? 'browser' : undefined
const email = emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_receive_types[]
}
if (privateUser && !privateUser.notificationSubscriptionTypes) {
updatePrivateUser(privateUser.id, {
notificationSubscriptionTypes: {
// Watched Markets
all_comments: constructPref(
wantsAll,
!privateUser.unsubscribedFromCommentEmails
),
all_answers: constructPref(
wantsAll,
!privateUser.unsubscribedFromAnswerEmails
),
// Comments
tipped_comments: constructPref(wantsAll || wantsLess, true),
comments_by_followed_users: constructPref(wantsAll, false), //wantsAll ? browserOnly : none,
all_replies_to_my_comments: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
all_replies_to_my_answers: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
// Answers
answers_by_followed_users: constructPref(
wantsAll || wantsLess,
!privateUser.unsubscribedFromAnswerEmails
), //wantsAll || wantsLess ? both : none,
answers_by_market_creator: constructPref(
wantsAll || wantsLess,
!privateUser.unsubscribedFromAnswerEmails
), //wantsAll || wantsLess ? both : none,
// On users' markets
my_markets_closed: constructPref(
wantsAll || wantsLess,
!privateUser.unsubscribedFromResolutionEmails
), //wantsAll || wantsLess ? both : none, // High priority
all_comments_on_my_markets: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
all_answers_on_my_markets: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
// Market updates
resolutions: constructPref(wantsAll || wantsLess, true),
market_updates: constructPref(wantsAll || wantsLess, false),
//Balance Changes
loans: browserOnly,
betting_streaks: browserOnly,
referral_bonuses: both,
unique_bettor_bonuses: browserOnly,
// General
user_tagged_you: constructPref(wantsAll || wantsLess, true), //wantsAll || wantsLess ? both : none,
new_markets_by_followed_users: constructPref(
wantsAll || wantsLess,
true
), //wantsAll || wantsLess ? both : none,
trending_markets: constructPref(
false,
!privateUser.unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: emailOnly,
} as exhaustive_notification_subscribe_types,
})
}
if (!privateUser || !privateUser.notificationSubscriptionTypes) { if (!privateUser || !privateUser.notificationSubscriptionTypes) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
@ -120,22 +47,28 @@ export function NotificationSettings() {
'new_markets_by_followed_users', 'new_markets_by_followed_users',
'trending_markets', 'trending_markets',
'profit_loss_updates', 'profit_loss_updates',
'all_comments_on_contracts_with_shares_in',
'all_answers_on_contracts_with_shares_in',
] ]
const browserDisabled = ['trending_markets', 'profit_loss_updates'] const browserDisabled = ['trending_markets', 'profit_loss_updates']
const watched_markets_explanations_comments: { const watched_markets_explanations_comments: {
[key in keyof Partial<exhaustive_notification_subscribe_types>]: string [key in keyof Partial<exhaustive_notification_subscribe_types>]: string
} = { } = {
all_comments: 'All', all_comments_on_watched_markets: 'All',
// tipped_comments: 'Tipped', // tipped_comments: 'Tipped',
// comments_by_followed_users: 'By followed users', // comments_by_followed_users: 'By followed users',
all_replies_to_my_comments: 'Replies to your comments', all_replies_to_my_comments_on_watched_markets: 'Replies to your comments',
all_comments_on_contracts_with_shares_in_on_watched_markets:
'On markets you have shares in',
} }
const watched_markets_explanations_answers: { const watched_markets_explanations_answers: {
[key in keyof Partial<exhaustive_notification_subscribe_types>]: string [key in keyof Partial<exhaustive_notification_subscribe_types>]: string
} = { } = {
all_answers: 'All', all_answers_on_watched_markets: 'All',
all_replies_to_my_answers: 'Replies to your answers', all_replies_to_my_answers_on_watched_markets: 'Replies to your answers',
all_answers_on_contracts_with_shares_in_on_watched_markets:
'On markets you have shares in',
// answers_by_followed_users: 'By followed users', // answers_by_followed_users: 'By followed users',
// answers_by_market_creator: 'Submitted by the market creator', // answers_by_market_creator: 'Submitted by the market creator',
} }
@ -149,15 +82,15 @@ export function NotificationSettings() {
const watched_markets_explanations_market_updates: { const watched_markets_explanations_market_updates: {
[key in keyof Partial<exhaustive_notification_subscribe_types>]: string [key in keyof Partial<exhaustive_notification_subscribe_types>]: string
} = { } = {
resolutions: 'Market resolutions', resolutions_on_watched_markets: 'Market resolutions',
market_updates: 'Updates made by the creator', market_updates_on_watched_markets: 'Updates made by the creator',
// probability_updates: 'Changes in probability', // probability_updates: 'Changes in probability',
} }
const balance_change_explanations: { const balance_change_explanations: {
[key in keyof Partial<exhaustive_notification_subscribe_types>]: string [key in keyof Partial<exhaustive_notification_subscribe_types>]: string
} = { } = {
loans: 'Automatic loans from your profitable bets', loan_income: 'Automatic loans from your profitable bets',
betting_streaks: 'Betting streak bonuses', betting_streaks: 'Betting streak bonuses',
referral_bonuses: 'Referral bonuses from referring users', referral_bonuses: 'Referral bonuses from referring users',
unique_bettor_bonuses: 'Unique bettor bonuses on your markets', unique_bettor_bonuses: 'Unique bettor bonuses on your markets',
@ -175,7 +108,7 @@ export function NotificationSettings() {
const NotificationSettingLine = ( const NotificationSettingLine = (
description: string, description: string,
key: string, key: string,
value: notification_receive_types[] value: notification_destination_types[]
) => { ) => {
const previousInAppValue = value.includes('browser') const previousInAppValue = value.includes('browser')
const previousEmailValue = value.includes('email') const previousEmailValue = value.includes('email')
@ -348,7 +281,7 @@ export function NotificationSettings() {
)} )}
{Section( {Section(
<UserIcon className={'h-6 w-6'} />, <UserIcon className={'h-6 w-6'} />,
'On Your Markets', 'On Markets You Created',
watched_markets_explanations_your_markets watched_markets_explanations_your_markets
)} )}
{Section( {Section(

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
import { Notification } from 'common/notification' import { Notification } from 'common/notification'
import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { getNotificationsQuery } from 'web/lib/firebase/notifications'
import { groupBy, map, partition } from 'lodash' import { groupBy, map, partition } from 'lodash'
@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) {
if (!result.data) return undefined if (!result.data) return undefined
const notifications = result.data as Notification[] const notifications = result.data as Notification[]
return getAppropriateNotifications( return notifications.filter((n) => !n.isSeenOnHref)
notifications, }, [result.data])
privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref)
}, [privateUser.notificationPreferences, result.data])
return notifications return notifications
} }
@ -112,28 +109,28 @@ export function groupNotifications(notifications: Notification[]) {
return notificationGroups return notificationGroups
} }
const lessPriorityReasons = [ // const lessPriorityReasons = [
'on_contract_with_users_comment', // 'on_contract_with_users_comment',
'on_contract_with_users_answer', // 'on_contract_with_users_answer',
// Notifications not currently generated for users who've sold their shares // // Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out', // 'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less: // // Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in', // // 'on_contract_with_users_shares_in',
] // ]
function getAppropriateNotifications( // function getAppropriateNotifications(
notifications: Notification[], // notifications: Notification[],
notificationPreferences?: notification_subscribe_types // notificationPreferences?: notification_subscribe_types
) { // ) {
if (notificationPreferences === 'all') return notifications // if (notificationPreferences === 'all') return notifications
if (notificationPreferences === 'less') // if (notificationPreferences === 'less')
return notifications.filter( // return notifications.filter(
(n) => // (n) =>
n.reason && // n.reason &&
// Show all contract notifications and any that aren't in the above list: // // Show all contract notifications and any that aren't in the above list:
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) // (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
) // )
if (notificationPreferences === 'none') return [] // if (notificationPreferences === 'none') return []
//
return notifications // return notifications
} // }

View File

@ -1003,7 +1003,7 @@ function getReasonForShowingNotification(
else reasonText = justSummary ? `commented` : `commented on` else reasonText = justSummary ? `commented` : `commented on`
break break
case 'contract': case 'contract':
if (reason === 'you_follow_user') if (reason === 'contract_from_followed_user')
reasonText = justSummary ? 'asked the question' : 'asked' reasonText = justSummary ? 'asked the question' : 'asked'
else if (sourceUpdateType === 'resolved') else if (sourceUpdateType === 'resolved')
reasonText = justSummary ? `resolved the question` : `resolved` reasonText = justSummary ? `resolved the question` : `resolved`
@ -1011,7 +1011,8 @@ function getReasonForShowingNotification(
else reasonText = justSummary ? 'updated the question' : `updated` else reasonText = justSummary ? 'updated the question' : `updated`
break break
case 'answer': case 'answer':
if (reason === 'on_users_contract') reasonText = `answered your question ` if (reason === 'answer_on_your_contract')
reasonText = `answered your question `
else reasonText = `answered` else reasonText = `answered`
break break
case 'follow': case 'follow':