diff --git a/common/envs/prod.ts b/common/envs/prod.ts
index 2b1ee70e..b3b552eb 100644
--- a/common/envs/prod.ts
+++ b/common/envs/prod.ts
@@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
+ 'federicoruizcassarino@gmail.com', // Fede
],
visibility: 'PUBLIC',
diff --git a/common/group.ts b/common/group.ts
index 19f3b7b8..871bc821 100644
--- a/common/group.ts
+++ b/common/group.ts
@@ -12,7 +12,18 @@ export type Group = {
aboutPostId?: string
chatDisabled?: boolean
mostRecentContractAddedTime?: number
+ cachedLeaderboard?: {
+ topTraders: {
+ userId: string
+ score: number
+ }[]
+ topCreators: {
+ userId: string
+ score: number
+ }[]
+ }
}
+
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
diff --git a/common/notification.ts b/common/notification.ts
index 9ec320fa..42dbbf35 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -1,3 +1,6 @@
+import { notification_subscription_types, PrivateUser } from './user'
+import { DOMAIN } from './envs/constants'
+
export type Notification = {
id: string
userId: string
@@ -51,28 +54,106 @@ export type notification_source_update_types =
| 'deleted'
| 'closed'
+/* Optional - if possible use a keyof notification_subscription_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_new_follow'
- | 'you_follow_user'
- | 'added_you_to_group'
+ | 'contract_from_followed_user'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'
- | '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'
- | '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'
+ | 'subsidized_your_market'
+
+// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
+// You might want to add a key:value here if there will be multiple notification reasons that map to the same
+// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to
+// 'all_comments_on_watched_markets' subscription type
+// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types
+export const notificationReasonToSubscriptionType: Partial<
+ Record
+> = {
+ you_referred_user: 'referral_bonuses',
+ user_joined_to_bet_on_your_market: 'referral_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',
+ 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_on_watched_markets_with_shares_in',
+ 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',
+}
+
+export const getDestinationsForUser = async (
+ privateUser: PrivateUser,
+ reason: notification_reason_types | keyof notification_subscription_types
+) => {
+ const notificationSettings = privateUser.notificationSubscriptionTypes
+ let destinations
+ let subscriptionType: keyof notification_subscription_types | undefined
+ if (Object.keys(notificationSettings).includes(reason)) {
+ subscriptionType = reason as keyof notification_subscription_types
+ destinations = notificationSettings[subscriptionType]
+ } else {
+ const key = reason as notification_reason_types
+ subscriptionType = notificationReasonToSubscriptionType[key]
+ destinations = subscriptionType
+ ? notificationSettings[subscriptionType]
+ : []
+ }
+ return {
+ sendToEmail: destinations.includes('email'),
+ sendToBrowser: destinations.includes('browser'),
+ urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
+ }
+}
diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts
index 7d4a0185..bf6f5ebc 100644
--- a/common/payouts-dpm.ts
+++ b/common/payouts-dpm.ts
@@ -13,7 +13,6 @@ import { addObjects } from './util/object'
export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => {
const { pool } = contract
const poolTotal = sum(Object.values(pool))
- console.log('resolved N/A, pool M$', poolTotal)
const betSum = sumBy(bets, (b) => b.amount)
@@ -58,17 +57,6 @@ export const getDpmStandardPayouts = (
liquidityFee: 0,
})
- console.log(
- 'resolved',
- outcome,
- 'pool',
- poolTotal,
- 'profits',
- profits,
- 'creator fee',
- creatorFee
- )
-
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
@@ -110,17 +98,6 @@ export const getNumericDpmPayouts = (
liquidityFee: 0,
})
- console.log(
- 'resolved numeric bucket: ',
- outcome,
- 'pool',
- poolTotal,
- 'profits',
- profits,
- 'creator fee',
- creatorFee
- )
-
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
@@ -163,17 +140,6 @@ export const getDpmMktPayouts = (
liquidityFee: 0,
})
- console.log(
- 'resolved MKT',
- p,
- 'pool',
- pool,
- 'profits',
- profits,
- 'creator fee',
- creatorFee
- )
-
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
@@ -216,16 +182,6 @@ export const getPayoutsMultiOutcome = (
liquidityFee: 0,
})
- console.log(
- 'resolved',
- resolutions,
- 'pool',
- poolTotal,
- 'profits',
- profits,
- 'creator fee',
- creatorFee
- )
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts
index 4b8de85a..99e03fac 100644
--- a/common/payouts-fixed.ts
+++ b/common/payouts-fixed.ts
@@ -1,4 +1,3 @@
-import { sum } from 'lodash'
import { Bet } from './bet'
import { getProbability } from './calculate'
@@ -43,18 +42,6 @@ export const getStandardFixedPayouts = (
const { collectedFees } = contract
const creatorPayout = collectedFees.creatorFee
-
- console.log(
- 'resolved',
- outcome,
- 'pool',
- contract.pool[outcome],
- 'payouts',
- sum(payouts),
- 'creator fee',
- creatorPayout
- )
-
const liquidityPayouts = getLiquidityPoolPayouts(
contract,
outcome,
@@ -98,18 +85,6 @@ export const getMktFixedPayouts = (
const { collectedFees } = contract
const creatorPayout = collectedFees.creatorFee
-
- console.log(
- 'resolved PROB',
- p,
- 'pool',
- p * contract.pool.YES + (1 - p) * contract.pool.NO,
- 'payouts',
- sum(payouts),
- 'creator fee',
- creatorPayout
- )
-
const liquidityPayouts = getLiquidityPoolProbPayouts(contract, p, liquidities)
return { payouts, creatorPayout, liquidityPayouts, collectedFees }
diff --git a/common/redeem.ts b/common/redeem.ts
index e0839ff8..f786a1c2 100644
--- a/common/redeem.ts
+++ b/common/redeem.ts
@@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0)
- const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
+ const soldFrac =
+ shares > 0
+ ? Math.min(yesShares, noShares) / Math.max(yesShares, noShares)
+ : 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment
diff --git a/common/scoring.ts b/common/scoring.ts
index 39a342fd..4ef46534 100644
--- a/common/scoring.ts
+++ b/common/scoring.ts
@@ -1,8 +1,8 @@
-import { groupBy, sumBy, mapValues, partition } from 'lodash'
+import { groupBy, sumBy, mapValues } from 'lodash'
import { Bet } from './bet'
+import { getContractBetMetrics } from './calculate'
import { Contract } from './contract'
-import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
@@ -30,46 +30,8 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
}
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
- const { resolution } = contract
- const resolutionProb =
- contract.outcomeType == 'BINARY'
- ? contract.resolutionProbability
- : undefined
-
- const [closedBets, openBets] = partition(
- bets,
- (bet) => bet.isSold || bet.sale
- )
- const { payouts: resolvePayouts } = getPayouts(
- resolution as string,
- contract,
- openBets,
- [],
- {},
- resolutionProb
- )
-
- const salePayouts = closedBets.map((bet) => {
- const { userId, sale } = bet
- return { userId, payout: sale ? sale.amount : 0 }
- })
-
- const investments = bets
- .filter((bet) => !bet.sale)
- .map((bet) => {
- const { userId, amount, loanAmount } = bet
- const payout = -amount - (loanAmount ?? 0)
- return { userId, payout }
- })
-
- const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]
-
- const userScore = mapValues(
- groupBy(netPayouts, (payout) => payout.userId),
- (payouts) => sumBy(payouts, ({ payout }) => payout)
- )
-
- return userScore
+ const betsByUser = groupBy(bets, bet => bet.userId)
+ return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
}
export function addUserScores(
diff --git a/common/user.ts b/common/user.ts
index 26d531cf..5d427744 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
@@ -34,7 +36,7 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
- homeSections?: { visible: string[]; hidden: string[] }
+ homeSections?: string[]
referredByUserId?: string
referredByContractId?: string
@@ -63,7 +65,9 @@ export type PrivateUser = {
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string
+ /** @deprecated - use notificationSubscriptionTypes */
notificationPreferences?: notification_subscribe_types
+ notificationSubscriptionTypes: notification_subscription_types
twitchInfo?: {
twitchName: string
controlToken: string
@@ -71,6 +75,55 @@ export type PrivateUser = {
}
}
+export type notification_destination_types = 'email' | 'browser'
+export type notification_subscription_types = {
+ // Watched Markets
+ all_comments_on_watched_markets: notification_destination_types[]
+ all_answers_on_watched_markets: notification_destination_types[]
+
+ // Comments
+ tipped_comments_on_watched_markets: notification_destination_types[]
+ comments_by_followed_users_on_watched_markets: notification_destination_types[]
+ all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
+ all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
+ all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
+
+ // Answers
+ 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
+ your_contract_closed: notification_destination_types[]
+ all_comments_on_my_markets: notification_destination_types[]
+ all_answers_on_my_markets: notification_destination_types[]
+ subsidized_your_market: notification_destination_types[]
+
+ // Market updates
+ resolutions_on_watched_markets: notification_destination_types[]
+ resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
+ market_updates_on_watched_markets: notification_destination_types[]
+ market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
+ probability_updates_on_watched_markets: notification_destination_types[]
+
+ // Balance Changes
+ loan_income: notification_destination_types[]
+ betting_streaks: notification_destination_types[]
+ referral_bonuses: notification_destination_types[]
+ unique_bettors_on_your_contract: notification_destination_types[]
+ tips_on_your_comments: notification_destination_types[]
+ tips_on_your_markets: notification_destination_types[]
+ limit_order_fills: notification_destination_types[]
+
+ // General
+ tagged_user: notification_destination_types[]
+ on_new_follow: notification_destination_types[]
+ contract_from_followed_user: notification_destination_types[]
+ trending_markets: notification_destination_types[]
+ profit_loss_updates: notification_destination_types[]
+ onboarding_flow: notification_destination_types[]
+ thank_you_for_purchases: notification_destination_types[]
+}
export type notification_subscribe_types = 'all' | 'less' | 'none'
export type PortfolioMetrics = {
@@ -83,3 +136,140 @@ 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,
+ unsubscribedFromGenericEmails,
+ } = 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
+ ),
+ all_replies_to_my_comments_on_watched_markets: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromCommentEmails
+ ),
+ all_replies_to_my_answers_on_watched_markets: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromCommentEmails
+ ),
+ 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
+ ),
+ answers_by_market_creator_on_watched_markets: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromAnswerEmails
+ ),
+ all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
+ wantsAll,
+ !unsubscribedFromAnswerEmails
+ ),
+
+ // On users' markets
+ your_contract_closed: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromResolutionEmails
+ ), // High priority
+ all_comments_on_my_markets: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromCommentEmails
+ ),
+ all_answers_on_my_markets: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromAnswerEmails
+ ),
+ subsidized_your_market: constructPref(wantsAll || wantsLess, true),
+
+ // Market updates
+ resolutions_on_watched_markets: constructPref(
+ wantsAll || wantsLess,
+ !unsubscribedFromResolutionEmails
+ ),
+ market_updates_on_watched_markets: constructPref(
+ wantsAll || wantsLess,
+ false
+ ),
+ market_updates_on_watched_markets_with_shares_in: 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_bettors_on_your_contract: 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
+ tagged_user: constructPref(wantsAll || wantsLess, true),
+ on_new_follow: constructPref(wantsAll || wantsLess, true),
+ contract_from_followed_user: constructPref(wantsAll || wantsLess, true),
+ trending_markets: constructPref(
+ false,
+ !unsubscribedFromWeeklyTrendingEmails
+ ),
+ profit_loss_updates: constructPref(false, true),
+ probability_updates_on_watched_markets: constructPref(
+ wantsAll || wantsLess,
+ false
+ ),
+ thank_you_for_purchases: constructPref(
+ false,
+ !unsubscribedFromGenericEmails
+ ),
+ onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
+ } as notification_subscription_types
+}
diff --git a/common/util/parse.ts b/common/util/parse.ts
index 4fac3225..0bbd5cd9 100644
--- a/common/util/parse.ts
+++ b/common/util/parse.ts
@@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
+import { find } from 'linkifyjs'
import { uniq } from 'lodash'
+/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
+export function getUrl(text: string) {
+ const results = find(text, 'url')
+ return results.length ? results[0].href : null
+}
+
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) =>
diff --git a/docs/docs/api.md b/docs/docs/api.md
index e284abdf..64e26de8 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -60,23 +60,27 @@ Parameters:
Requires no authorization.
-### `GET /v0/groups/[slug]`
+### `GET /v0/group/[slug]`
Gets a group by its slug.
-Requires no authorization.
+Requires no authorization.
+Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
-Requires no authorization.
+Requires no authorization.
+Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
-Requires no authorization.
+Requires no authorization.
+Note: group is singular in the URL.
+
### `GET /v0/markets`
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 bf1aea3a..d24d4097 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -77,7 +77,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
- .hasOnly(['apiKey', 'twitchInfo', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
+ .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]);
}
match /private-users/{userId}/views/{viewId} {
@@ -161,7 +161,7 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isSeen', 'viewTime']);
}
-
+
match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
@@ -170,7 +170,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)
@@ -184,7 +184,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/change-user-info.ts b/functions/src/change-user-info.ts
index aa041856..ca66f1ba 100644
--- a/functions/src/change-user-info.ts
+++ b/functions/src/change-user-info.ts
@@ -37,6 +37,45 @@ export const changeUser = async (
avatarUrl?: string
}
) => {
+ // Update contracts, comments, and answers outside of a transaction to avoid contention.
+ // Using bulkWriter to supports >500 writes at a time
+ const contractsRef = firestore
+ .collection('contracts')
+ .where('creatorId', '==', user.id)
+
+ const contracts = await contractsRef.get()
+
+ const contractUpdate: Partial = removeUndefinedProps({
+ creatorName: update.name,
+ creatorUsername: update.username,
+ creatorAvatarUrl: update.avatarUrl,
+ })
+
+ const commentSnap = await firestore
+ .collectionGroup('comments')
+ .where('userUsername', '==', user.username)
+ .get()
+
+ const commentUpdate: Partial = removeUndefinedProps({
+ userName: update.name,
+ userUsername: update.username,
+ userAvatarUrl: update.avatarUrl,
+ })
+
+ const answerSnap = await firestore
+ .collectionGroup('answers')
+ .where('username', '==', user.username)
+ .get()
+ const answerUpdate: Partial = removeUndefinedProps(update)
+
+ const bulkWriter = firestore.bulkWriter()
+ commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
+ answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
+ contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
+ await bulkWriter.flush()
+ console.log('Done writing!')
+
+ // Update the username inside a transaction
return await firestore.runTransaction(async (transaction) => {
if (update.username) {
update.username = cleanUsername(update.username)
@@ -58,42 +97,7 @@ export const changeUser = async (
const userRef = firestore.collection('users').doc(user.id)
const userUpdate: Partial = removeUndefinedProps(update)
-
- const contractsRef = firestore
- .collection('contracts')
- .where('creatorId', '==', user.id)
-
- const contracts = await transaction.get(contractsRef)
-
- const contractUpdate: Partial = removeUndefinedProps({
- creatorName: update.name,
- creatorUsername: update.username,
- creatorAvatarUrl: update.avatarUrl,
- })
-
- const commentSnap = await transaction.get(
- firestore
- .collectionGroup('comments')
- .where('userUsername', '==', user.username)
- )
-
- const commentUpdate: Partial = removeUndefinedProps({
- userName: update.name,
- userUsername: update.username,
- userAvatarUrl: update.avatarUrl,
- })
-
- const answerSnap = await transaction.get(
- firestore
- .collectionGroup('answers')
- .where('username', '==', user.username)
- )
- const answerUpdate: Partial = removeUndefinedProps(update)
-
transaction.update(userRef, userUpdate)
- commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate))
- answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate))
- contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate))
})
}
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..84edf715 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -1,13 +1,12 @@
import * as admin from 'firebase-admin'
import {
+ getDestinationsForUser,
Notification,
notification_reason_types,
- notification_source_update_types,
- notification_source_types,
} 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,20 +14,27 @@ 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 {
+ sendMarketCloseEmail,
+ sendMarketResolutionEmail,
+ sendNewAnswerEmail,
+ sendNewCommentEmail,
+ sendNewFollowedMarketEmail,
+} from './emails'
+import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
-type user_to_reason_texts = {
+type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
}
export const createNotification = async (
sourceId: string,
- sourceType: notification_source_types,
- sourceUpdateType: notification_source_update_types,
+ sourceType: 'contract' | 'liquidity' | 'follow',
+ sourceUpdateType: 'closed' | 'created',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
@@ -41,9 +47,9 @@ export const createNotification = async (
) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
- const shouldGetNotification = (
+ const shouldReceiveNotification = (
userId: string,
- userToReasonTexts: user_to_reason_texts
+ userToReasonTexts: recipients_to_reason_texts
) => {
return (
sourceUser.id != userId &&
@@ -51,18 +57,25 @@ export const createNotification = async (
)
}
- const createUsersNotifications = async (
- userToReasonTexts: user_to_reason_texts
+ const sendNotificationsIfSettingsPermit = async (
+ userToReasonTexts: recipients_to_reason_texts
) => {
- await Promise.all(
- Object.keys(userToReasonTexts).map(async (userId) => {
+ for (const userId in userToReasonTexts) {
+ const { reason } = userToReasonTexts[userId]
+ const privateUser = await getPrivateUser(userId)
+ if (!privateUser) continue
+ const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
+ privateUser,
+ reason
+ )
+ if (sendToBrowser) {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
- reason: userToReasonTexts[userId].reason,
+ reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
@@ -80,212 +93,232 @@ export const createNotification = async (
sourceTitle: title ? title : sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
- })
- )
- }
-
- const notifyUsersFollowers = async (
- userToReasonTexts: user_to_reason_texts
- ) => {
- const followers = await firestore
- .collectionGroup('follows')
- .where('userId', '==', sourceUser.id)
- .get()
-
- followers.docs.forEach((doc) => {
- const followerUserId = doc.ref.parent.parent?.id
- if (
- followerUserId &&
- shouldGetNotification(followerUserId, userToReasonTexts)
- ) {
- userToReasonTexts[followerUserId] = {
- reason: 'you_follow_user',
- }
}
- })
- }
- const notifyFollowedUser = (
- userToReasonTexts: user_to_reason_texts,
- followedUserId: string
- ) => {
- if (shouldGetNotification(followedUserId, userToReasonTexts))
- userToReasonTexts[followedUserId] = {
- reason: 'on_new_follow',
+ if (!sendToEmail) continue
+
+ if (reason === 'your_contract_closed' && privateUser && sourceContract) {
+ // TODO: include number and names of bettors waiting for creator to resolve their market
+ await sendMarketCloseEmail(
+ reason,
+ sourceUser,
+ privateUser,
+ sourceContract
+ )
+ } else if (reason === 'subsidized_your_market') {
+ // TODO: send email to creator of market that was subsidized
+ } else if (reason === 'on_new_follow') {
+ // TODO: send email to user who was followed
}
+ }
}
- const notifyTaggedUsers = (
- userToReasonTexts: user_to_reason_texts,
- userIds: (string | undefined)[]
- ) => {
- userIds.forEach((id) => {
- if (id && shouldGetNotification(id, userToReasonTexts))
- userToReasonTexts[id] = {
- reason: 'tagged_user',
- }
- })
- }
-
- const notifyContractCreator = async (
- userToReasonTexts: user_to_reason_texts,
- sourceContract: Contract,
- options?: { force: boolean }
- ) => {
- if (
- options?.force ||
- shouldGetNotification(sourceContract.creatorId, userToReasonTexts)
- )
- userToReasonTexts[sourceContract.creatorId] = {
- reason: 'on_users_contract',
- }
- }
-
- const notifyUserAddedToGroup = (
- userToReasonTexts: user_to_reason_texts,
- relatedUserId: string
- ) => {
- if (shouldGetNotification(relatedUserId, userToReasonTexts))
- userToReasonTexts[relatedUserId] = {
- reason: 'added_you_to_group',
- }
- }
-
- const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
+ const userToReasonTexts: recipients_to_reason_texts = {}
if (sourceType === 'follow' && recipients?.[0]) {
- notifyFollowedUser(userToReasonTexts, recipients[0])
- } else if (
- sourceType === 'group' &&
- sourceUpdateType === 'created' &&
- recipients
- ) {
- recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
- } else if (
- sourceType === 'contract' &&
- sourceUpdateType === 'created' &&
- sourceContract
- ) {
- await notifyUsersFollowers(userToReasonTexts)
- notifyTaggedUsers(userToReasonTexts, recipients ?? [])
+ if (shouldReceiveNotification(recipients[0], userToReasonTexts))
+ userToReasonTexts[recipients[0]] = {
+ reason: 'on_new_follow',
+ }
+ return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
- await notifyContractCreator(userToReasonTexts, sourceContract, {
- force: true,
- })
+ userToReasonTexts[sourceContract.creatorId] = {
+ reason: 'your_contract_closed',
+ }
+ return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
- await notifyContractCreator(userToReasonTexts, sourceContract)
+ if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
+ userToReasonTexts[sourceContract.creatorId] = {
+ reason: 'subsidized_your_market',
+ }
+ return await sendNotificationsIfSettingsPermit(userToReasonTexts)
}
+}
- await createUsersNotifications(userToReasonTexts)
+export type replied_users_info = {
+ [key: string]: {
+ repliedToType: 'comment' | 'answer'
+ repliedToAnswerText: string | undefined
+ repliedToId: string | undefined
+ bet: Bet | undefined
+ }
}
export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
- sourceType: 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
- repliedUserId?: string
- taggedUserIds?: string[]
+ repliedUsersInfo: replied_users_info
+ taggedUserIds: string[]
+ },
+ resolutionData?: {
+ bets: Bet[]
+ userInvestments: { [userId: string]: number }
+ userPayouts: { [userId: string]: number }
+ creator: User
+ creatorPayout: number
+ contract: Contract
+ outcome: string
+ resolutionProbability?: number
+ resolutions?: { [outcome: string]: number }
}
) => {
- const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
+ const { repliedUsersInfo, taggedUserIds } = 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 browserRecipientIdsList: string[] = []
+ const emailRecipientIdsList: string[] = []
- // get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
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
+ )
+ return
+ const privateUser = await getPrivateUser(userId)
+ if (!privateUser) return
+ const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
+ privateUser,
+ reason
)
- }
- const notifyContractFollowers = async (
- userToReasonTexts: user_to_reason_texts
- ) => {
- for (const userId of contractFollowersIds) {
- if (shouldGetNotification(userId, userToReasonTexts))
- userToReasonTexts[userId] = {
- reason: 'you_follow_contract',
- }
+ // Browser notifications
+ if (sendToBrowser && !browserRecipientIdsList.includes(userId)) {
+ await createBrowserNotification(userId, reason)
+ browserRecipientIdsList.push(userId)
+ }
+
+ // Emails notifications
+ if (!sendToEmail || emailRecipientIdsList.includes(userId)) return
+ if (sourceType === 'comment') {
+ const { repliedToType, repliedToAnswerText, repliedToId, bet } =
+ repliedUsersInfo?.[userId] ?? {}
+ // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment
+ await sendNewCommentEmail(
+ reason,
+ privateUser,
+ sourceUser,
+ sourceContract,
+ sourceText,
+ sourceId,
+ bet,
+ repliedToAnswerText,
+ repliedToType === 'answer' ? repliedToId : undefined
+ )
+ emailRecipientIdsList.push(userId)
+ } else if (sourceType === 'answer') {
+ await sendNewAnswerEmail(
+ reason,
+ privateUser,
+ sourceUser.name,
+ sourceText,
+ sourceContract,
+ sourceUser.avatarUrl
+ )
+ emailRecipientIdsList.push(userId)
+ } else if (
+ sourceType === 'contract' &&
+ sourceUpdateType === 'resolved' &&
+ resolutionData
+ ) {
+ await sendMarketResolutionEmail(
+ reason,
+ privateUser,
+ resolutionData.userInvestments[userId] ?? 0,
+ resolutionData.userPayouts[userId] ?? 0,
+ sourceUser,
+ resolutionData.creatorPayout,
+ sourceContract,
+ resolutionData.outcome,
+ resolutionData.resolutionProbability,
+ resolutionData.resolutions
+ )
+ emailRecipientIdsList.push(userId)
}
}
- const 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 +326,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 +350,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 +382,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,
- 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',
- }
- }
- }
+ const notifyRepliedUser = async () => {
+ if (sourceType === 'comment' && repliedUsersInfo)
+ await Promise.all(
+ Object.keys(repliedUsersInfo).map((userId) =>
+ sendNotificationsIfSettingsPermit(
+ userId,
+ repliedUsersInfo[userId].repliedToType === 'answer'
+ ? 'reply_to_users_answer'
+ : 'reply_to_users_comment'
+ )
+ )
+ )
}
- const notifyTaggedUsers = (
- 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 () => {
+ if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0)
+ await Promise.all(
+ taggedUserIds.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 ?? [])
- }
- 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 notifyRepliedUser()
+ await notifyTaggedUsers()
+ await notifyContractCreator()
+ await notifyOtherAnswerersOnContract()
+ await notifyLiquidityProviders()
+ await notifyBettorsOnContract()
+ await notifyOtherCommentersOnContract()
+ // if they weren't notified previously, notify them now
+ await notifyContractFollowers()
}
export const createTipNotification = async (
@@ -436,8 +464,15 @@ export const createTipNotification = async (
contract?: Contract,
group?: Group
) => {
- const slug = group ? group.slug + `#${commentId}` : commentId
+ const privateUser = await getPrivateUser(toUser.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'tip_received'
+ )
+ if (!sendToBrowser) return
+ const slug = group ? group.slug + `#${commentId}` : commentId
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@@ -461,6 +496,9 @@ export const createTipNotification = async (
sourceTitle: group?.name,
}
return await notificationRef.set(removeUndefinedProps(notification))
+
+ // TODO: send notification to users that are watching the contract and want highly tipped comments only
+ // maybe TODO: send email notification to bet creator
}
export const createBetFillNotification = async (
@@ -471,6 +509,14 @@ export const createBetFillNotification = async (
contract: Contract,
idempotencyKey: string
) => {
+ const privateUser = await getPrivateUser(toUser.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'bet_fill'
+ )
+ if (!sendToBrowser) return
+
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0
@@ -496,38 +542,8 @@ export const createBetFillNotification = async (
sourceContractId: contract.id,
}
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))
+ // maybe TODO: send email notification to bet creator
}
export const createReferralNotification = async (
@@ -538,6 +554,14 @@ export const createReferralNotification = async (
referredByContract?: Contract,
referredByGroup?: Group
) => {
+ const privateUser = await getPrivateUser(toUser.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'you_referred_user'
+ )
+ if (!sendToBrowser) return
+
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@@ -575,6 +599,8 @@ export const createReferralNotification = async (
: referredByContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
+
+ // TODO send email notification
}
export const createLoanIncomeNotification = async (
@@ -582,6 +608,14 @@ export const createLoanIncomeNotification = async (
idempotencyKey: string,
income: number
) => {
+ const privateUser = await getPrivateUser(toUser.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'loan_income'
+ )
+ if (!sendToBrowser) return
+
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@@ -612,6 +646,14 @@ export const createChallengeAcceptedNotification = async (
acceptedAmount: number,
contract: Contract
) => {
+ const privateUser = await getPrivateUser(challengeCreator.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'challenge_accepted'
+ )
+ if (!sendToBrowser) return
+
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
@@ -645,6 +687,14 @@ export const createBettingStreakBonusNotification = async (
amount: number,
idempotencyKey: string
) => {
+ const privateUser = await getPrivateUser(user.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'betting_streak_incremented'
+ )
+ if (!sendToBrowser) return
+
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc(idempotencyKey)
@@ -680,13 +730,24 @@ export const createLikeNotification = async (
contract: Contract,
tip?: TipTxn
) => {
+ const privateUser = await getPrivateUser(toUser.id)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'liked_and_tipped_your_contract'
+ )
+ if (!sendToBrowser) return
+
+ // not handling just likes, must include tip
+ if (!tip) return
+
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
- reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
+ reason: 'liked_and_tipped_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: like.id,
@@ -703,20 +764,8 @@ export const createLikeNotification = async (
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
-}
-export async function filterUserIdsForOnlyFollowerIds(
- userIds: string[],
- contractId: string
-) {
- // get contract follower documents and check here if they're a follower
- const contractFollowersSnap = await firestore
- .collection(`contracts/${contractId}/follows`)
- .get()
- const contractFollowersIds = contractFollowersSnap.docs.map(
- (doc) => doc.data().id
- )
- return userIds.filter((id) => contractFollowersIds.includes(id))
+ // TODO send email notification
}
export const createUniqueBettorBonusNotification = async (
@@ -727,6 +776,15 @@ export const createUniqueBettorBonusNotification = async (
amount: number,
idempotencyKey: string
) => {
+ console.log('createUniqueBettorBonusNotification')
+ const privateUser = await getPrivateUser(contractCreatorId)
+ if (!privateUser) return
+ const { sendToBrowser } = await getDestinationsForUser(
+ privateUser,
+ 'unique_bettors_on_your_contract'
+ )
+ if (!sendToBrowser) return
+
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
@@ -752,4 +810,82 @@ export const createUniqueBettorBonusNotification = async (
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
+
+ // TODO send email notification
+}
+
+export const createNewContractNotification = async (
+ contractCreator: User,
+ contract: Contract,
+ idempotencyKey: string,
+ text: string,
+ mentionedUserIds: string[]
+) => {
+ if (contract.visibility !== 'public') return
+
+ const sendNotificationsIfSettingsAllow = async (
+ userId: string,
+ reason: notification_reason_types
+ ) => {
+ const privateUser = await getPrivateUser(userId)
+ if (!privateUser) return
+ const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
+ privateUser,
+ reason
+ )
+ if (sendToBrowser) {
+ const notificationRef = firestore
+ .collection(`/users/${userId}/notifications`)
+ .doc(idempotencyKey)
+ const notification: Notification = {
+ id: idempotencyKey,
+ userId: userId,
+ reason,
+ createdTime: Date.now(),
+ isSeen: false,
+ sourceId: contract.id,
+ sourceType: 'contract',
+ sourceUpdateType: 'created',
+ sourceUserName: contractCreator.name,
+ sourceUserUsername: contractCreator.username,
+ sourceUserAvatarUrl: contractCreator.avatarUrl,
+ sourceText: text,
+ sourceSlug: contract.slug,
+ sourceTitle: contract.question,
+ sourceContractSlug: contract.slug,
+ sourceContractId: contract.id,
+ sourceContractTitle: contract.question,
+ sourceContractCreatorUsername: contract.creatorUsername,
+ }
+ await notificationRef.set(removeUndefinedProps(notification))
+ }
+ if (!sendToEmail) return
+ if (reason === 'contract_from_followed_user')
+ await sendNewFollowedMarketEmail(reason, userId, privateUser, contract)
+ }
+ const followersSnapshot = await firestore
+ .collectionGroup('follows')
+ .where('userId', '==', contractCreator.id)
+ .get()
+
+ const followerUserIds = filterDefined(
+ followersSnapshot.docs.map((doc) => {
+ const followerUserId = doc.ref.parent.parent?.id
+ return followerUserId && followerUserId != contractCreator.id
+ ? followerUserId
+ : undefined
+ })
+ )
+
+ // As it is coded now, the tag notification usurps the new contract notification
+ // It'd be easy to append the reason to the eventId if desired
+ for (const followerUserId of followerUserIds) {
+ await sendNotificationsIfSettingsAllow(
+ followerUserId,
+ 'contract_from_followed_user'
+ )
+ }
+ for (const mentionedUserId of mentionedUserIds) {
+ await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
+ }
}
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/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html
index 6c75f026..c8f6a171 100644
--- a/functions/src/email-templates/500-mana.html
+++ b/functions/src/email-templates/500-mana.html
@@ -284,9 +284,12 @@
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html
index a61e8d65..c73f7458 100644
--- a/functions/src/email-templates/creating-market.html
+++ b/functions/src/email-templates/creating-market.html
@@ -186,8 +186,9 @@
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
- ">Did you know you create your own prediction market on Manifold for
+ ">Did you know you can create your own prediction market on Manifold on
any question you care about?
@@ -490,10 +491,10 @@
">
This e-mail has been sent to {{name}},
- click here to unsubscribe .
+ " target="_blank">click here to manage your notifications.
diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html
index d00b227e..7c3e653d 100644
--- a/functions/src/email-templates/interesting-markets.html
+++ b/functions/src/email-templates/interesting-markets.html
@@ -440,11 +440,10 @@
This e-mail has been sent to
{{name}},
- click here to unsubscribe from future recommended markets.
+ " target="_blank">click here to manage your notifications.
diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html
index 4e1a2bfa..a19aa7c3 100644
--- a/functions/src/email-templates/market-answer-comment.html
+++ b/functions/src/email-templates/market-answer-comment.html
@@ -526,19 +526,10 @@
"
>our Discord! Or,
- unsubscribe .
+ click here to manage your notifications .
diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html
index 1f7fa5fa..b2d7f727 100644
--- a/functions/src/email-templates/market-answer.html
+++ b/functions/src/email-templates/market-answer.html
@@ -367,14 +367,9 @@
margin: 0;
">our Discord! Or,
unsubscribe .
+ color: inherit;
+ text-decoration: none;
+ " target="_blank">click here to manage your notifications.
diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html
index fa44c1d5..ee7976b0 100644
--- a/functions/src/email-templates/market-close.html
+++ b/functions/src/email-templates/market-close.html
@@ -485,14 +485,9 @@
margin: 0;
">our Discord! Or,
unsubscribe .
+ color: inherit;
+ text-decoration: none;
+ " target="_blank">click here to manage your notifications.
diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html
index 0b5b9a54..23e20dac 100644
--- a/functions/src/email-templates/market-comment.html
+++ b/functions/src/email-templates/market-comment.html
@@ -367,14 +367,9 @@
margin: 0;
">our Discord! Or,
unsubscribe .
+ color: inherit;
+ text-decoration: none;
+ " target="_blank">click here to manage your notifications.
diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html
new file mode 100644
index 00000000..ff5f541f
--- /dev/null
+++ b/functions/src/email-templates/market-resolved-no-bets.html
@@ -0,0 +1,491 @@
+
+
+
+
+
+
+ Market resolved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{creatorName}} asked
+
+
+
+
+
+ {{question}}
+
+
+
+
+
+ Resolved {{outcome}}
+
+
+
+
+
+
+
+
+ Dear {{name}},
+
+
+ A market you were following has been resolved!
+
+
+ Thanks,
+
+ Manifold Team
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html
index c1ff3beb..de29a0f1 100644
--- a/functions/src/email-templates/market-resolved.html
+++ b/functions/src/email-templates/market-resolved.html
@@ -500,14 +500,9 @@
margin: 0;
">our Discord! Or,
unsubscribe .
+ color: inherit;
+ text-decoration: none;
+ " target="_blank">click here to manage your notifications.
diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html
new file mode 100644
index 00000000..877d554f
--- /dev/null
+++ b/functions/src/email-templates/new-market-from-followed-user.html
@@ -0,0 +1,354 @@
+
+
+
+
+ New market from {{creatorName}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{creatorName}}, (who you're following) just created a new market, check it out!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html
index 94889772..b8e233d5 100644
--- a/functions/src/email-templates/one-week.html
+++ b/functions/src/email-templates/one-week.html
@@ -1,519 +1,316 @@
-
-
- 7th Day Anniversary Gift!
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Hopefully you haven't gambled all your M$
- away already... but if you have I bring good
- news! Click the link below to recieve a one time
- gift of M$ 500 to your account!
-
-
-
-
-
-
-
-
-
-
-
-
- << /td>
-
-
-
-
-
-
-
-
-
-
- If you are still engaging with our markets then
- at this point you might as well join our Discord server .
- You can always leave if you dont like it but
- I'd be willing to make a market betting
- you'll stay.
-
-
-
-
- Cheers,
-
-
- David from Manifold
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
Thanks for
+ using Manifold Markets. Running low
+ on mana (M$)? Click the link below to receive a one time gift of M$500!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Did
+ you know, besides making correct predictions, there are
+ plenty of other ways to earn mana?
+
+
+
Cheers,
+
+
David
+ from Manifold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
+ " target="_blank">click here to manage your notifications.
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-