diff --git a/common/notification.ts b/common/notification.ts index 9ec320fa..6efbda85 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,3 +1,5 @@ +import { exhaustive_notification_subscribe_types } from 'common/user' + export type Notification = { id: string userId: string @@ -53,26 +55,91 @@ export type notification_source_update_types = export type notification_reason_types = | 'tagged_user' - | 'on_users_contract' - | 'on_contract_with_users_shares_in' - | 'on_contract_with_users_shares_out' - | 'on_contract_with_users_answer' - | 'on_contract_with_users_comment' - | 'reply_to_users_answer' - | 'reply_to_users_comment' + // | 'on_users_contract' + // | 'on_contract_with_users_shares_in' + // | 'on_contract_with_users_shares_out' + // | 'on_contract_with_users_answer' + // | 'on_contract_with_users_comment' | 'on_new_follow' - | 'you_follow_user' + | 'contract_from_followed_user' | 'added_you_to_group' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' - | 'on_group_you_are_member_of' + // | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' - | 'you_follow_contract' + // | 'you_follow_contract' | 'liked_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', +} diff --git a/common/user.ts b/common/user.ts index 4c729389..7fadd277 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { filterDefined } from './util/array' + export type User = { id: string createdTime: number @@ -67,44 +69,53 @@ export type PrivateUser = { 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 = { // Watched Markets - all_comments: notification_receive_types[] // Email currently - seems bad - all_answers: notification_receive_types[] // Email currently - seems bad + all_comments_on_watched_markets: notification_destination_types[] // Email currently - seems bad + all_answers_on_watched_markets: notification_destination_types[] // Email currently - seems bad // Comments - tipped_comments: notification_receive_types[] // Email - comments_by_followed_users: notification_receive_types[] - all_replies_to_my_comments: notification_receive_types[] // Email - all_replies_to_my_answers: notification_receive_types[] // Email + tipped_comments_on_watched_markets: notification_destination_types[] // Email + comments_by_followed_users_on_watched_markets: notification_destination_types[] + all_replies_to_my_comments_on_watched_markets: notification_destination_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_by_followed_users: notification_receive_types[] - answers_by_market_creator: notification_receive_types[] + answers_by_followed_users_on_watched_markets: notification_destination_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 - my_markets_closed: notification_receive_types[] // Email, Recommended - all_comments_on_my_markets: notification_receive_types[] // Email - all_answers_on_my_markets: notification_receive_types[] // Email + my_markets_closed: notification_destination_types[] // Email, Recommended + all_comments_on_my_markets: notification_destination_types[] // Email + all_answers_on_my_markets: notification_destination_types[] // Email // Market updates - resolutions: notification_receive_types[] // Email - market_updates: notification_receive_types[] - probability_updates: notification_receive_types[] // Email - would want persistent changes only though + resolutions_on_watched_markets: notification_destination_types[] // Email + resolutions_on_watched_markets_with_shares_in: notification_destination_types[] // Email + 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 - loans: notification_receive_types[] - betting_streaks: notification_receive_types[] - referral_bonuses: notification_receive_types[] - unique_bettor_bonuses: notification_receive_types[] + loan_income: notification_destination_types[] + betting_streaks: notification_destination_types[] + referral_bonuses: notification_destination_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 - user_tagged_you: notification_receive_types[] // Email - new_markets_by_followed_users: notification_receive_types[] // Email - trending_markets: notification_receive_types[] // Email - profit_loss_updates: notification_receive_types[] // Email + user_tagged_you: notification_destination_types[] // Email + new_followers: notification_destination_types[] // Email + group_adds: notification_destination_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' @@ -118,3 +129,131 @@ export type PortfolioMetrics = { export const MANIFOLD_USERNAME = 'ManifoldMarkets' 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 +} diff --git a/firebase.json b/firebase.json index 25f9b61f..5dea5ade 100644 --- a/firebase.json +++ b/firebase.json @@ -2,10 +2,30 @@ "functions": { "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions/dist" + "source": "functions/dist", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] }, "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "pubsub": { + "port": 8085 + }, + "ui": { + "enabled": true + } } } diff --git a/firestore.rules b/firestore.rules index eeee9b26..42e5e668 100644 --- a/firestore.rules +++ b/firestore.rules @@ -159,7 +159,7 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isSeen', 'viewTime']); } - + match /{somePath=**}/groupMembers/{memberId} { allow read; } @@ -168,7 +168,7 @@ service cloud.firestore { allow read; } - match /groups/{groupId} { + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) @@ -182,7 +182,7 @@ service cloud.firestore { 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 delete: if request.auth.uid == resource.data.userId; + allow delete: if request.auth.uid == resource.data.userId; } function isGroupMember() { diff --git a/functions/.gitignore b/functions/.gitignore index 58f30dcb..bd3d0c29 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -17,4 +17,5 @@ package-lock.json ui-debug.log firebase-debug.log firestore-debug.log +pubsub-debug.log firestore_export/ diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 0b8b4e7a..cc05d817 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -5,8 +5,7 @@ import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' -import { getContract, getValues } from './utils' -import { sendNewAnswerEmail } from './emails' +import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' const bodySchema = z.object({ @@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - return answer }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index fc64aeff..9d00bb0b 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -10,7 +10,7 @@ import { MAX_GROUP_NAME_LENGTH, MAX_ID_LENGTH, } from '../../common/group' -import { APIError, newEndpoint, validate } from '../../functions/src/api' +import { APIError, newEndpoint, validate } from './api' import { z } from 'zod' const bodySchema = z.object({ diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 131d6e85..bbece366 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -4,10 +4,11 @@ import { notification_reason_types, notification_source_update_types, notification_source_types, + notificationReasonToSubscribeTypeMap, } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues, log } from './utils' +import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -15,13 +16,17 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Group } from '../../common/group' import { Challenge } from '../../common/challenge' -import { richTextToString } from '../../common/util/parse' import { Like } from '../../common/like' +import { + sendMarketResolutionEmail, + sendNewAnswerEmail, + sendNewCommentEmail, +} from './emails' const firestore = admin.firestore() -type user_to_reason_texts = { +type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } @@ -43,7 +48,7 @@ export const createNotification = async ( const shouldGetNotification = ( userId: string, - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { return ( sourceUser.id != userId && @@ -52,17 +57,21 @@ export const createNotification = async ( } const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { await Promise.all( Object.keys(userToReasonTexts).map(async (userId) => { + const { reason } = userToReasonTexts[userId] + const { sendToBrowser } = await getDestinationsForUser(userId, reason) + if (!sendToBrowser) return Promise.resolve() + const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, - reason: userToReasonTexts[userId].reason, + reason, createdTime: Date.now(), isSeen: false, sourceId, @@ -85,7 +94,7 @@ export const createNotification = async ( } const notifyUsersFollowers = async ( - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { const followers = await firestore .collectionGroup('follows') @@ -99,14 +108,14 @@ export const createNotification = async ( shouldGetNotification(followerUserId, userToReasonTexts) ) { userToReasonTexts[followerUserId] = { - reason: 'you_follow_user', + reason: 'contract_from_followed_user', } } }) } const notifyFollowedUser = ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, followedUserId: string ) => { if (shouldGetNotification(followedUserId, userToReasonTexts)) @@ -116,7 +125,7 @@ export const createNotification = async ( } const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, userIds: (string | undefined)[] ) => { userIds.forEach((id) => { @@ -128,7 +137,7 @@ export const createNotification = async ( } const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, sourceContract: Contract, options?: { force: boolean } ) => { @@ -137,12 +146,12 @@ export const createNotification = async ( shouldGetNotification(sourceContract.creatorId, userToReasonTexts) ) userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', + reason: 'your_contract_closed', } } const notifyUserAddedToGroup = ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, relatedUserId: string ) => { 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. if (sourceType === 'follow' && recipients?.[0]) { @@ -188,54 +197,63 @@ export const createNotification = async ( 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 ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'comment' | 'answer' | 'contract', + sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUser: User, idempotencyKey: string, sourceText: string, sourceContract: Contract, miscData?: { - relatedSourceType?: notification_source_types + repliedToType?: 'comment' | 'answer' + repliedToId?: string + repliedToContent?: string repliedUserId?: 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 ( - 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)) - }) - ) - } + const recipientIdsList: string[] = [] // get contract follower documents and check here if they're a follower const contractFollowersSnap = await firestore @@ -244,48 +262,128 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( const contractFollowersIds = contractFollowersSnap.docs.map( (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) => { return contractFollowersIds.includes(userId) } - const shouldGetNotification = ( + const sendNotificationsIfSettingsPermit = async ( userId: string, - userToReasonTexts: user_to_reason_texts + reason: notification_reason_types ) => { - return ( - sourceUser.id != userId && - !Object.keys(userToReasonTexts).includes(userId) + if ( + !stillFollowingContract(sourceContract.creatorId) || + sourceUser.id == userId || + recipientIdsList.includes(userId) ) - } + return - const notifyContractFollowers = async ( - userToReasonTexts: user_to_reason_texts - ) => { - for (const userId of contractFollowersIds) { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'you_follow_contract', - } + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + userId, + reason + ) + + if (sendToBrowser) { + 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 ( - userToReasonTexts: user_to_reason_texts - ) => { - if ( - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && - stillFollowingContract(sourceContract.creatorId) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } + const notifyContractFollowers = async () => { + for (const userId of contractFollowersIds) { + await sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_you_follow' + : sourceType === 'comment' + ? 'comment_on_contract_you_follow' + : sourceUpdateType === 'updated' + ? 'update_on_contract_you_follow' + : 'resolution_on_contract_you_follow' + ) + } } - const notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyContractCreator = async () => { + await sendNotificationsIfSettingsPermit( + sourceContract.creatorId, + sourceType === 'comment' + ? 'comment_on_your_contract' + : 'answer_on_your_contract' + ) + } + + const notifyOtherAnswerersOnContract = async () => { const answers = await getValues( firestore .collection('contracts') @@ -293,20 +391,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('answers') ) const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_answer' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_answer' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_answer' + : 'resolution_on_contract_with_users_answer' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) + ) } - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyOtherCommentersOnContract = async () => { const comments = await getValues( firestore .collection('contracts') @@ -314,20 +415,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('comments') ) const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_comment' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_comment' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_comment' + : 'resolution_on_contract_with_users_comment' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) + ) } - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyBettorsOnContract = async () => { const betsSnap = await firestore .collection(`contracts/${sourceContract.id}/bets`) .get() @@ -343,88 +447,77 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } ) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) + ) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, + const notifyRepliedUser = async ( relatedUserId: string, relatedSourceType: notification_source_types ) => { - if ( - shouldGetNotification(relatedUserId, userToReasonTexts) && - stillFollowingContract(relatedUserId) - ) { - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } + await sendNotificationsIfSettingsPermit( + relatedUserId, + relatedSourceType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - 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 notifyTaggedUsers = async (userIds: string[]) => { + await Promise.all( + userIds.map((userId) => + sendNotificationsIfSettingsPermit(userId, 'tagged_user') + ) + ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyLiquidityProviders = async () => { const liquidityProviders = await firestore .collection(`contracts/${sourceContract.id}/liquidity`) .get() const liquidityProvidersIds = uniq( liquidityProviders.docs.map((doc) => doc.data().userId) ) - liquidityProvidersIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) - ) { - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - } - }) + await Promise.all( + liquidityProvidersIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) + ) + ) } - const userToReasonTexts: user_to_reason_texts = {} if (sourceType === 'comment') { - if (repliedUserId && relatedSourceType) - notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) + if (repliedUserId && repliedToType) + await notifyRepliedUser(repliedUserId, repliedToType) + await notifyTaggedUsers(taggedUserIds ?? []) } - await notifyContractCreator(userToReasonTexts) - await notifyOtherAnswerersOnContract(userToReasonTexts) - await notifyLiquidityProviders(userToReasonTexts) - await notifyBettorsOnContract(userToReasonTexts) - await notifyOtherCommentersOnContract(userToReasonTexts) - // if they weren't added previously, add them now - await notifyContractFollowers(userToReasonTexts) - - await createUsersNotifications(userToReasonTexts) + await notifyContractCreator() + await notifyOtherAnswerersOnContract() + await notifyLiquidityProviders() + await notifyBettorsOnContract() + await notifyOtherCommentersOnContract() + // if they weren't notified previously, notify them now + await notifyContractFollowers() } export const createTipNotification = async ( @@ -436,8 +529,13 @@ export const createTipNotification = async ( contract?: Contract, 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 .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -471,6 +569,9 @@ export const createBetFillNotification = async ( contract: Contract, idempotencyKey: string ) => { + const { sendToBrowser } = await getDestinationsForUser(toUser.id, 'bet_fill') + if (!sendToBrowser) return + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 @@ -498,38 +599,6 @@ export const createBetFillNotification = async ( 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 ( toUser: User, referredUser: User, @@ -538,6 +607,12 @@ export const createReferralNotification = async ( referredByContract?: Contract, referredByGroup?: Group ) => { + const { sendToBrowser } = await getDestinationsForUser( + toUser.id, + 'you_referred_user' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -582,6 +657,12 @@ export const createLoanIncomeNotification = async ( idempotencyKey: string, income: number ) => { + const { sendToBrowser } = await getDestinationsForUser( + toUser.id, + 'loan_income' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -612,6 +693,12 @@ export const createChallengeAcceptedNotification = async ( acceptedAmount: number, contract: Contract ) => { + const { sendToBrowser } = await getDestinationsForUser( + challengeCreator.id, + 'challenge_accepted' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${challengeCreator.id}/notifications`) .doc() @@ -645,6 +732,12 @@ export const createBettingStreakBonusNotification = async ( amount: number, idempotencyKey: string ) => { + const { sendToBrowser } = await getDestinationsForUser( + user.id, + 'betting_streak_incremented' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${user.id}/notifications`) .doc(idempotencyKey) @@ -680,6 +773,12 @@ export const createLikeNotification = async ( contract: Contract, tip?: TipTxn ) => { + const { sendToBrowser } = await getDestinationsForUser( + toUser.id, + 'liked_and_tipped_your_contract' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -727,6 +826,12 @@ export const createUniqueBettorBonusNotification = async ( amount: number, idempotencyKey: string ) => { + const { sendToBrowser } = await getDestinationsForUser( + contractCreatorId, + 'unique_bettors_on_your_contract' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${contractCreatorId}/notifications`) .doc(idempotencyKey) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index eabe0fd0..71272222 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,7 +1,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { PrivateUser, User } from '../../common/user' +import { + getDefaultNotificationSettings, + PrivateUser, + User, +} from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, + notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 2c9c6f12..77d75769 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,8 +1,6 @@ import { DOMAIN } from '../../common/envs/constants' -import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' -import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { @@ -18,6 +16,7 @@ import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' import { richTextToString } from '../../common/util/parse' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' +import { JSONContent } from '@tiptap/core' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -346,7 +345,8 @@ export const sendNewCommentEmail = async ( userId: string, commentCreator: User, contract: Contract, - comment: Comment, + commentContent: JSONContent | string, + commentId: string, bet?: Bet, answerText?: string, answerId?: string @@ -359,14 +359,17 @@ export const sendNewCommentEmail = async ( ) return - const { question, creatorUsername, slug } = contract - const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` + const { question } = contract + const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}` const emailType = 'market-comment' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) + + const text = + typeof commentContent !== 'string' + ? richTextToString(commentContent) + : commentContent let betDescription = '' if (bet) { @@ -380,7 +383,7 @@ export const sendNewCommentEmail = async ( const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { - const answerNumber = `#${answerId}` + const answerNumber = answerId ? `#${answerId}` : '' return await sendTemplateEmail( privateUser.email, @@ -423,14 +426,15 @@ export const sendNewCommentEmail = async ( } export const sendNewAnswerEmail = async ( - answer: Answer, - contract: Contract + userId: string, + name: string, + text: string, + contract: Contract, + avatarUrl?: string ) => { - // Send to just the creator for now. - const { creatorId: userId } = contract - + const { creatorId } = contract // Don't send the creator's own answers. - if (answer.userId === userId) return + if (userId === creatorId) return const privateUser = await getPrivateUser(userId) if ( @@ -441,7 +445,6 @@ export const sendNewAnswerEmail = async ( return const { question, creatorUsername, slug } = contract - const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` const emailType = 'market-answer' diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a36a8bca..a46420bc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,15 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { compact, uniq } from 'lodash' +import { compact } from 'lodash' import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' -import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { - createCommentOrAnswerOrUpdatedContractNotification, - filterUserIdsForOnlyFollowerIds, -} from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) - const relatedSourceType = comment.replyToCommentId - ? 'comment' - : comment.answerOutcome + const repliedToType = answer ? 'answer' + : comment.replyToCommentId + ? 'comment' : undefined const repliedUserId = comment.replyToCommentId @@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - relatedSourceType, + repliedToType, + repliedToId: comment.replyToCommentId || answer?.id, + repliedToContent: answer ? answer.text : undefined, repliedUserId, 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 - ) - ) - ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d7ecd56e..2972a305 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore if (!contractUpdater) throw new Error('Could not find contract updater') const previousValue = change.before.data() as Contract - if (previousValue.isResolved !== contract.isResolved) { - 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 ( + if ( previousValue.closeTime !== contract.closeTime || previousValue.question !== contract.question ) { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6f8ea2e9..e5fc0121 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -8,10 +8,8 @@ import { MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' -import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' -import { sendMarketResolutionEmail } from './emails' import { getLoanPayouts, getPayouts, @@ -23,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 { floatingEqual } from '../../common/util/math' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' const bodySchema = z.object({ contractId: z.string(), @@ -163,15 +161,44 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - await sendResolutionEmails( - bets, - userPayoutsWithoutLoans, + const userInvestments = mapValues( + groupBy(bets, (bet) => bet.userId), + (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, - creatorPayout, + contract.id + '-resolution', + resolutionText, contract, - outcome, - resolutionProbability, - resolutions + undefined, + { + bets, + userInvestments, + userPayouts: userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions, + } ) return updatedContract @@ -188,51 +215,51 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { .catch((e) => ({ status: 'error', message: e })) .then(() => ({ status: 'success' })) } - -const sendResolutionEmails = async ( - bets: Bet[], - userPayouts: { [userId: string]: number }, - creator: User, - creatorPayout: number, - contract: Contract, - outcome: string, - resolutionProbability?: number, - resolutions?: { [outcome: string]: number } -) => { - const investedByUser = mapValues( - groupBy(bets, (bet) => bet.userId), - (bets) => getContractBetMetrics(contract, bets).invested - ) - const investedUsers = Object.keys(investedByUser).filter( - (userId) => !floatingEqual(investedByUser[userId], 0) - ) - - const nonWinners = difference(investedUsers, Object.keys(userPayouts)) - const emailPayouts = [ - ...Object.entries(userPayouts), - ...nonWinners.map((userId) => [userId, 0] as const), - ].map(([userId, payout]) => ({ - userId, - investment: investedByUser[userId] ?? 0, - payout, - })) - - await Promise.all( - emailPayouts.map(({ userId, investment, payout }) => - sendMarketResolutionEmail( - userId, - investment, - payout, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - ) - ) -} +// +// const sendResolutionEmails = async ( +// bets: Bet[], +// userPayouts: { [userId: string]: number }, +// creator: User, +// creatorPayout: number, +// contract: Contract, +// outcome: string, +// resolutionProbability?: number, +// resolutions?: { [outcome: string]: number } +// ) => { +// const investedByUser = mapValues( +// groupBy(bets, (bet) => bet.userId), +// (bets) => getContractBetMetrics(contract, bets).invested +// ) +// const investedUsers = Object.keys(investedByUser).filter( +// (userId) => !floatingEqual(investedByUser[userId], 0) +// ) +// +// const nonWinners = difference(investedUsers, Object.keys(userPayouts)) +// const emailPayouts = [ +// ...Object.entries(userPayouts), +// ...nonWinners.map((userId) => [userId, 0] as const), +// ].map(([userId, payout]) => ({ +// userId, +// investment: investedByUser[userId] ?? 0, +// payout, +// })) +// +// await Promise.all( +// emailPayouts.map(({ userId, investment, payout }) => +// sendMarketResolutionEmail( +// userId, +// investment, +// payout, +// creator, +// creatorPayout, +// contract, +// outcome, +// resolutionProbability, +// resolutions +// ) +// ) +// ) +// } function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts new file mode 100644 index 00000000..a6bd1a0b --- /dev/null +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -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()) diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index acce446e..f9b8c3a1 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { PrivateUser, User } from 'common/user' +import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' const firestore = admin.firestore() @@ -21,6 +21,7 @@ async function main() { id: user.id, email, username, + notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index b0c7b30b..0187a370 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -5,7 +5,7 @@ import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { exhaustive_notification_subscribe_types, - notification_receive_types, + notification_destination_types, } from 'common/user' import { updatePrivateUser } from 'web/lib/firebase/users' import { Switch } from '@headlessui/react' @@ -28,79 +28,6 @@ import toast from 'react-hot-toast' export function NotificationSettings() { const privateUser = usePrivateUser() 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) { return @@ -120,22 +47,28 @@ export function NotificationSettings() { 'new_markets_by_followed_users', 'trending_markets', '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 watched_markets_explanations_comments: { [key in keyof Partial]: string } = { - all_comments: 'All', + all_comments_on_watched_markets: 'All', // tipped_comments: 'Tipped', // 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: { [key in keyof Partial]: string } = { - all_answers: 'All', - all_replies_to_my_answers: 'Replies to your answers', + all_answers_on_watched_markets: 'All', + 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_market_creator: 'Submitted by the market creator', } @@ -149,15 +82,15 @@ export function NotificationSettings() { const watched_markets_explanations_market_updates: { [key in keyof Partial]: string } = { - resolutions: 'Market resolutions', - market_updates: 'Updates made by the creator', + resolutions_on_watched_markets: 'Market resolutions', + market_updates_on_watched_markets: 'Updates made by the creator', // probability_updates: 'Changes in probability', } const balance_change_explanations: { [key in keyof Partial]: string } = { - loans: 'Automatic loans from your profitable bets', + loan_income: 'Automatic loans from your profitable bets', betting_streaks: 'Betting streak bonuses', referral_bonuses: 'Referral bonuses from referring users', unique_bettor_bonuses: 'Unique bettor bonuses on your markets', @@ -175,7 +108,7 @@ export function NotificationSettings() { const NotificationSettingLine = ( description: string, key: string, - value: notification_receive_types[] + value: notification_destination_types[] ) => { const previousInAppValue = value.includes('browser') const previousEmailValue = value.includes('email') @@ -348,7 +281,7 @@ export function NotificationSettings() { )} {Section( , - 'On Your Markets', + 'On Markets You Created', watched_markets_explanations_your_markets )} {Section( diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 473facd4..00672a1a 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' +import { PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { groupBy, map, partition } from 'lodash' @@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) { if (!result.data) return undefined const notifications = result.data as Notification[] - return getAppropriateNotifications( - notifications, - privateUser.notificationPreferences - ).filter((n) => !n.isSeenOnHref) - }, [privateUser.notificationPreferences, result.data]) + return notifications.filter((n) => !n.isSeenOnHref) + }, [result.data]) return notifications } @@ -112,28 +109,28 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -const lessPriorityReasons = [ - 'on_contract_with_users_comment', - 'on_contract_with_users_answer', - // Notifications not currently generated for users who've sold their shares - 'on_contract_with_users_shares_out', - // Not sure if users will want to see these w/ less: - // 'on_contract_with_users_shares_in', -] +// const lessPriorityReasons = [ +// 'on_contract_with_users_comment', +// 'on_contract_with_users_answer', +// // Notifications not currently generated for users who've sold their shares +// 'on_contract_with_users_shares_out', +// // Not sure if users will want to see these w/ less: +// // 'on_contract_with_users_shares_in', +// ] -function getAppropriateNotifications( - notifications: Notification[], - notificationPreferences?: notification_subscribe_types -) { - if (notificationPreferences === 'all') return notifications - if (notificationPreferences === 'less') - return notifications.filter( - (n) => - n.reason && - // Show all contract notifications and any that aren't in the above list: - (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) - ) - if (notificationPreferences === 'none') return [] - - return notifications -} +// function getAppropriateNotifications( +// notifications: Notification[], +// notificationPreferences?: notification_subscribe_types +// ) { +// if (notificationPreferences === 'all') return notifications +// if (notificationPreferences === 'less') +// return notifications.filter( +// (n) => +// n.reason && +// // Show all contract notifications and any that aren't in the above list: +// (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) +// ) +// if (notificationPreferences === 'none') return [] +// +// return notifications +// } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 8d97a75b..897e3ca6 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1003,7 +1003,7 @@ function getReasonForShowingNotification( else reasonText = justSummary ? `commented` : `commented on` break case 'contract': - if (reason === 'you_follow_user') + if (reason === 'contract_from_followed_user') reasonText = justSummary ? 'asked the question' : 'asked' else if (sourceUpdateType === 'resolved') reasonText = justSummary ? `resolved the question` : `resolved` @@ -1011,7 +1011,8 @@ function getReasonForShowingNotification( else reasonText = justSummary ? 'updated the question' : `updated` break case 'answer': - if (reason === 'on_users_contract') reasonText = `answered your question ` + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` else reasonText = `answered` break case 'follow':