diff --git a/common/comment.ts b/common/comment.ts index cdb62fd3..71c04af4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -18,6 +18,7 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: string + bountiesAwarded?: number } & T export type OnContract = { diff --git a/common/contract.ts b/common/contract.ts index 248c9745..1255874d 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -62,6 +62,9 @@ export type Contract = { featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + flaggedByUsernames?: string[] + openCommentBounties?: number + unlistedById?: string } & T export type BinaryContract = Contract & Binary diff --git a/common/economy.ts b/common/economy.ts index 7ec52b30..d25a0c71 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 +export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 diff --git a/common/envs/prod.ts b/common/envs/prod.ts index d0469d84..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -41,6 +41,7 @@ export type Economy = { BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number FREE_MARKETS_PER_USER_MAX?: number + COMMENT_BOUNTY_AMOUNT?: number } type FirebaseConfig = { diff --git a/common/group.ts b/common/group.ts index 5220a1e8..8f5728d3 100644 --- a/common/group.ts +++ b/common/group.ts @@ -23,6 +23,7 @@ export type Group = { score: number }[] } + pinnedItems: { itemId: string; type: 'post' | 'contract' }[] } export const MAX_GROUP_NAME_LENGTH = 75 diff --git a/common/like.ts b/common/like.ts index 38b25dad..7ec14726 100644 --- a/common/like.ts +++ b/common/like.ts @@ -5,4 +5,4 @@ export type Like = { createdTime: number tipTxnId?: string // only holds most recent tip txn id } -export const LIKE_TIP_AMOUNT = 5 +export const LIKE_TIP_AMOUNT = 10 diff --git a/common/notification.ts b/common/notification.ts index b42df541..b75e3d4a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -96,6 +96,7 @@ type notification_descriptions = { [key in notification_preference]: { simple: string detailed: string + necessary?: boolean } } export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { @@ -116,8 +117,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: "Only answers by market creator on markets you're watching", }, betting_streaks: { - simple: 'For predictions made over consecutive days', - detailed: 'Bonuses for predictions made over consecutive days', + simple: `For prediction streaks`, + detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`, }, comments_by_followed_users_on_watched_markets: { simple: 'Only comments by users you follow', @@ -159,8 +160,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Large changes in probability on markets that you watch', }, profit_loss_updates: { - simple: 'Weekly profit and loss updates', - detailed: 'Weekly profit and loss updates', + simple: 'Weekly portfolio updates', + detailed: 'Weekly portfolio updates', }, referral_bonuses: { simple: 'For referring new users', @@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Bonuses for unique predictors on your markets', }, your_contract_closed: { - simple: 'Your market has closed and you need to resolve it', - detailed: 'Your market has closed and you need to resolve it', + simple: 'Your market has closed and you need to resolve it (necessary)', + detailed: 'Your market has closed and you need to resolve it (necessary)', + necessary: true, }, all_comments_on_watched_markets: { simple: 'All new comments', @@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { simple: `Only on markets you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`, }, + opt_out_all: { + simple: 'Opt out of all notifications (excludes when your markets close)', + detailed: + 'Opt out of all notifications excluding your own market closure notifications', + }, } export type BettingStreakData = { diff --git a/common/txn.ts b/common/txn.ts index 2b7a32e8..c404059d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -8,6 +8,7 @@ type AnyTxnType = | UniqueBettorBonus | BettingStreakBonus | CancelUniqueBettorBonus + | CommentBountyRefund type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -31,6 +32,8 @@ export type Txn = { | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS' + | 'COMMENT_BOUNTY' + | 'REFUND_COMMENT_BOUNTY' // Any extra data data?: { [key: string]: any } @@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = { } } +type CommentBountyDeposit = { + fromType: 'USER' + toType: 'BANK' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + } +} + +type CommentBountyWithdrawal = { + fromType: 'BANK' + toType: 'USER' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + commentId: string + } +} + +type CommentBountyRefund = { + fromType: 'BANK' + toType: 'USER' + category: 'REFUND_COMMENT_BOUNTY' + data: { + contractId: string + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink @@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus +export type CommentBountyDepositTxn = Txn & CommentBountyDeposit +export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index 3fc0fb2f..ba9ade9d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -53,6 +53,9 @@ export type notification_preferences = { profit_loss_updates: notification_destination_types[] onboarding_flow: notification_destination_types[] thank_you_for_purchases: notification_destination_types[] + + opt_out_all: notification_destination_types[] + // When adding a new notification preference, use add-new-notification-preference.ts to existing users } export const getDefaultNotificationPreferences = ( @@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = ( const email = noEmails ? undefined : emailIf ? 'email' : undefined return filterDefined([browser, email]) as notification_destination_types[] } - return { + const defaults: notification_preferences = { // Watched Markets all_comments_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false), @@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = ( probability_updates_on_watched_markets: constructPref(true, false), thank_you_for_purchases: constructPref(false, false), onboarding_flow: constructPref(false, false), - } as notification_preferences + + opt_out_all: [], + } + return defaults } // Adding a new key:value here is optional, you can just use a key of notification_subscription_types @@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = ( ? notificationSettings[subscriptionType] : [] } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') return { - sendToEmail: destinations.includes('email'), - sendToBrowser: destinations.includes('browser'), + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } diff --git a/common/user.ts b/common/user.ts index b1365929..233fe4cc 100644 --- a/common/user.ts +++ b/common/user.ts @@ -33,6 +33,8 @@ export type User = { allTime: number } + fractionResolvedCorrectly: number + nextLoanCached: number followerCountCached: number diff --git a/common/util/format.ts b/common/util/format.ts index 4f123535..ee59d3e7 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', { }) export function formatMoney(amount: number) { - const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case + const newAmount = + // handle -0 case + Math.round(amount) === 0 + ? 0 + : // Handle 499.9999999999999 case + Math.floor(amount + 0.00000000001 * Math.sign(amount)) return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') } diff --git a/firestore.rules b/firestore.rules index 26649fa6..50f93e1f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -102,7 +102,7 @@ service cloud.firestore { allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime', 'question']) + .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { @@ -176,7 +176,7 @@ service cloud.firestore { allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]); allow delete: if request.auth.uid == resource.data.creatorId; match /groupContracts/{contractId} { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 76dc1298..4b3f7446 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => { totalContracts: 0, totalMembers: memberIds.length, postIds: [], + pinnedItems: [], } await groupRef.create(group) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 038e0142..9bd73d05 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async ( ) ) } + +export const createBountyNotification = async ( + fromUser: User, + toUserId: string, + amount: number, + idempotencyKey: string, + contract: Contract, + commentId?: string +) => { + const privateUser = await getPrivateUser(toUserId) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + + const slug = commentId + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'tip_received', + createdTime: Date.now(), + isSeen: false, + sourceId: commentId ? commentId : contract.id, + sourceType: 'tip', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: slug, + sourceTitle: contract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // maybe TODO: send email notification to comment creator +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab70b4e6..c3b7ba1d 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, + fractionResolvedCorrectly: 1, } await firestore.collection('users').doc(auth.uid).create(user) diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 4abd225e..b742c533 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -483,11 +483,7 @@ color: #999; text-decoration: underline; margin: 0; - ">our Discord! Or, - click here to unsubscribe from this type of notification. + ">our Discord! diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html index fd99837f..921a58e5 100644 --- a/functions/src/email-templates/weekly-portfolio-update.html +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -320,7 +320,7 @@ style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;" data-testid="4XoHRGw1Y"> - And here's some of the biggest changes in your portfolio: + And here's some recent changes in your investments:

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 6888cfb1..993fac81 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -643,13 +643,13 @@ export const sendWeeklyPortfolioUpdateEmail = async ( templateData[`question${i + 1}Title`] = investment.questionTitle templateData[`question${i + 1}Url`] = investment.questionUrl templateData[`question${i + 1}Prob`] = investment.questionProb - templateData[`question${i + 1}Change`] = formatMoney(investment.difference) - templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle + templateData[`question${i + 1}Change`] = formatMoney(investment.profit) + templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle }) await sendTemplateEmail( - // privateUser.email, - 'iansphilips@gmail.com', + privateUser.email, + // 'iansphilips@gmail.com', `Here's your weekly portfolio update!`, investments.length === 0 ? 'portfolio-update-no-movers' diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a8ec232..f5c45004 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -52,6 +52,7 @@ export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' export * from './close-market' +export * from './update-comment-bounty' import { health } from './health' import { transact } from './transact' @@ -65,6 +66,7 @@ import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares) const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addLiquidityFunction = toCloudFunction(addliquidity) +const addCommentBounty = toCloudFunction(addcommentbounty) +const awardCommentBounty = toCloudFunction(awardcommentbounty) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -127,4 +131,6 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + addCommentBounty as addcommentbounty, + awardCommentBounty as awardcommentbounty, } diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 5e2a94c0..301d6286 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -7,38 +7,47 @@ export const onUpdateContract = functions.firestore .document('contracts/{contractId}') .onUpdate(async (change, context) => { const contract = change.after.data() as Contract + const previousContract = change.before.data() as Contract const { eventId } = context - - const contractUpdater = await getUser(contract.creatorId) - if (!contractUpdater) throw new Error('Could not find contract updater') - - const previousValue = change.before.data() as Contract - - // Resolution is handled in resolve-market.ts - if (!previousValue.isResolved && contract.isResolved) return + const { openCommentBounties, closeTime, question } = contract if ( - previousValue.closeTime !== contract.closeTime || - previousValue.question !== contract.question + !previousContract.isResolved && + contract.isResolved && + (openCommentBounties ?? 0) > 0 ) { - let sourceText = '' - if ( - previousValue.closeTime !== contract.closeTime && - contract.closeTime - ) { - sourceText = contract.closeTime.toString() - } else if (previousValue.question !== contract.question) { - sourceText = contract.question - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'updated', - contractUpdater, - eventId, - sourceText, - contract - ) + // No need to notify users of resolution, that's handled in resolve-market + return + } + if ( + previousContract.closeTime !== closeTime || + previousContract.question !== question + ) { + await handleUpdatedCloseTime(previousContract, contract, eventId) } }) + +async function handleUpdatedCloseTime( + previousContract: Contract, + contract: Contract, + eventId: string +) { + const contractUpdater = await getUser(contract.creatorId) + if (!contractUpdater) throw new Error('Could not find contract updater') + let sourceText = '' + if (previousContract.closeTime !== contract.closeTime && contract.closeTime) { + sourceText = contract.closeTime.toString() + } else if (previousContract.question !== contract.question) { + sourceText = contract.question + } + + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'updated', + contractUpdater, + eventId, + sourceText, + contract + ) +} diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts new file mode 100644 index 00000000..d7e7072b --- /dev/null +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -0,0 +1,27 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllPrivateUsers } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + opt_out_all: [], + }, + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/scripts/contest/bulk-resolve-markets.ts b/functions/src/scripts/contest/bulk-resolve-markets.ts new file mode 100644 index 00000000..2fe07c92 --- /dev/null +++ b/functions/src/scripts/contest/bulk-resolve-markets.ts @@ -0,0 +1,63 @@ +// Run with `npx ts-node src/scripts/contest/resolve-markets.ts` + +const DOMAIN = 'dev.manifold.markets' +// Dev API key for Cause Exploration Prizes (@CEP) +const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf' +const GROUP_SLUG = 'cart-contest' + +// Can just curl /v0/group/{slug} to get a group +async function getGroupBySlug(slug: string) { + const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`) + return await resp.json() +} + +async function getMarketsByGroupId(id: string) { + // API structure: /v0/group/by-id/[id]/markets + const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`) + return await resp.json() +} + +/* Example curl request: +# Resolve a binary market +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES"}' +*/ +async function resolveMarketById( + id: string, + outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL' +) { + const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + outcome, + }), + }) + return await resp.json() +} + +async function main() { + const group = await getGroupBySlug(GROUP_SLUG) + const markets = await getMarketsByGroupId(group.id) + + // Count up some metrics + console.log('Number of markets', markets.length) + console.log( + 'Number of resolved markets', + markets.filter((m: any) => m.isResolved).length + ) + + // Resolve each market to NO + for (const market of markets) { + if (!market.isResolved) { + console.log(`Resolving market ${market.url} to NO`) + const resp = await resolveMarketById(market.id, 'NO') + } + } +} +main() diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index b2e4c4d8..e1330fe1 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -42,6 +42,7 @@ const createGroup = async ( totalContracts: contracts.length, totalMembers: 1, postIds: [], + pinnedItems: [], } await groupRef.create(group) // create a GroupMemberDoc for the creator diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 99ac6281..d861dcbc 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' import { testscheduledfunction } from './test-scheduled-function' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/addCommentBounty', addcommentbounty) +addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 418282c7..57a6d183 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -4,6 +4,7 @@ import { getPrivateUser } from './utils' import { PrivateUser } from '../../common/user' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { notification_preference } from '../../common/user-notification-preferences' +import { getFunctionUrl } from '../../common/api' export const unsubscribe: EndpointDefinition = { opts: { method: 'GET', minInstances: 1 }, @@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = { res.status(400).send('Invalid subscription type parameter.') return } + const optOutAllType: notification_preference = 'opt_out_all' + const wantsToOptOutAll = notificationSubscriptionType === optOutAllType const user = await getPrivateUser(id) @@ -37,17 +40,22 @@ export const unsubscribe: EndpointDefinition = { const update: Partial = { notificationPreferences: { ...user.notificationPreferences, - [notificationSubscriptionType]: previousDestinations.filter( - (destination) => destination !== 'email' - ), + [notificationSubscriptionType]: wantsToOptOutAll + ? previousDestinations.push('email') + : previousDestinations.filter( + (destination) => destination !== 'email' + ), }, } await firestore.collection('private-users').doc(id).update(update) + const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - res.send( - ` - + const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}` + if (wantsToOptOutAll) { + res.send( + ` + @@ -163,19 +171,6 @@ export const unsubscribe: EndpointDefinition = { - - -
-

- Hello!

-
- - @@ -186,20 +181,9 @@ export const unsubscribe: EndpointDefinition = { data-testid="4XoHRGw1Y"> - ${email} has been unsubscribed from email notifications related to: + ${email} has opted out of receiving unnecessary email notifications -
-
- ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. -

-
-
-
- Click - here - to manage the rest of your notification settings. - @@ -219,9 +203,193 @@ export const unsubscribe: EndpointDefinition = { +` + ) + } else { + res.send( + ` + + + + + Manifold Markets 7th Day Anniversary Gift! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${email} has been unsubscribed from email notifications related to: + +
+
+ + ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. +

+
+
+
+ Click + here + to unsubscribe from all unnecessary emails. + +
+
+ Click + here + to manage the rest of your notification settings. + +
+ +
+

+
+
+
+
+
+ ` - ) + ) + } }, } diff --git a/functions/src/update-comment-bounty.ts b/functions/src/update-comment-bounty.ts new file mode 100644 index 00000000..af1d6c0a --- /dev/null +++ b/functions/src/update-comment-bounty.ts @@ -0,0 +1,162 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { removeUndefinedProps } from '../../common/util/object' +import { APIError, newEndpoint, validate } from './api' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { isProd } from './utils' +import { + CommentBountyDepositTxn, + CommentBountyWithdrawalTxn, +} from '../../common/txn' +import { runTxn } from './transact' +import { Comment } from '../../common/comment' +import { createBountyNotification } from './create-notification' + +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) +const awardBodySchema = z.object({ + contractId: z.string(), + commentId: z.string(), + amount: z.number().gt(0), +}) + +export const addcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.balance < amount) + throw new APIError(400, 'Insufficient user balance') + + const newCommentBountyTxn = { + fromId: user.id, + fromType: 'USER', + toId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + toType: 'BANK', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + }, + description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`, + } as CommentBountyDepositTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: (contract.openCommentBounties ?? 0) + amount, + }) + ) + + return result + }) +}) +export const awardcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, commentId, contractId } = validate(awardBodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + const res = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.id !== contract.creatorId) + throw new APIError( + 400, + 'Only contract creator can award comment bounties' + ) + + const commentDoc = firestore.doc( + `contracts/${contractId}/comments/${commentId}` + ) + const commentSnap = await transaction.get(commentDoc) + if (!commentSnap.exists) throw new APIError(400, 'Invalid comment') + + const comment = commentSnap.data() as Comment + const amountAvailable = contract.openCommentBounties ?? 0 + if (amountAvailable < amount) + throw new APIError(400, 'Insufficient open bounty balance') + + const newCommentBountyTxn = { + fromId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + fromType: 'BANK', + toId: comment.userId, + toType: 'USER', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + commentId, + }, + description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`, + } as CommentBountyWithdrawalTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + await transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: amountAvailable - amount, + }) + ) + await transaction.update( + commentDoc, + removeUndefinedProps({ + bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount, + }) + ) + + return { ...result, comment, contract, user } + }) + if (res.txn?.id) { + const { comment, contract, user } = res + await createBountyNotification( + user, + comment.userId, + amount, + res.txn.id, + contract, + comment.id + ) + } + + return res +}) + +const firestore = admin.firestore() diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 12f41453..70c7c742 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -135,6 +135,28 @@ export async function updateMetricsCore() { lastPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + const contractRatios = userContracts + .map((contract) => { + if ( + !contract.flaggedByUsernames || + contract.flaggedByUsernames?.length === 0 + ) { + return 0 + } + const contractRatio = + contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) + + return contractRatio + }) + .filter((ratio) => ratio > 0) + const badResolutions = contractRatios.filter( + (ratio) => ratio > BAD_RESOLUTION_THRESHOLD + ) + let newFractionResolvedCorrectly = 0 + if (userContracts.length > 0) { + newFractionResolvedCorrectly = + (userContracts.length - badResolutions.length) / userContracts.length + } return { user, @@ -142,6 +164,7 @@ export async function updateMetricsCore() { newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, } }) @@ -163,6 +186,7 @@ export async function updateMetricsCore() { newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { @@ -172,6 +196,7 @@ export async function updateMetricsCore() { creatorVolumeCached: newCreatorVolume, profitCached: newProfit, nextLoanCached, + fractionResolvedCorrectly: newFractionResolvedCorrectly, }, }, @@ -243,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: number }) => { } type GroupContractDoc = { contractId: string; createdTime: number } + +const BAD_RESOLUTION_THRESHOLD = 0.1 diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts index 6f410886..a01bc87e 100644 --- a/functions/src/update-stats.ts +++ b/functions/src/update-stats.ts @@ -18,7 +18,7 @@ import { average } from '../../common/util/math' const firestore = admin.firestore() -const numberOfDays = 90 +const numberOfDays = 180 const getBetsQuery = (startTime: number, endTime: number) => firestore diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index 198fa7ca..bcf6da17 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -48,7 +48,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { return isProd() ? user.notificationPreferences.profit_loss_updates.includes('email') && !user.weeklyPortfolioUpdateEmailSent - : true + : user.notificationPreferences.profit_loss_updates.includes('email') }) // Send emails in batches .slice(0, 200) @@ -117,7 +117,8 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) - if (!user) return + // Don't send to a user unless they're over 5 days old + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -195,15 +196,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { contract, betsInLastWeek ).profit - const marketChange = - currentMarketProbability - marketProbabilityAWeekAgo const profit = betsMadeInLastWeekProfit + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) return { currentValue: currentBetsMadeAWeekAgoValue, pastValue: betsMadeAWeekAgoValue, - difference: profit, + profit, contractSlug: contract.slug, marketProbAWeekAgo: marketProbabilityAWeekAgo, questionTitle: contract.question, @@ -211,17 +210,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { questionProb: cpmmContract.resolution ? cpmmContract.resolution : Math.round(cpmmContract.prob * 100) + '%', - questionChange: - (marketChange > 0 ? '+' : '') + - Math.round(marketChange * 100) + - '%', - questionChangeStyle: `color: ${ + profitStyle: `color: ${ profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' };`, } as PerContractInvestmentsData }) ), - (differences) => Math.abs(differences.difference) + (differences) => Math.abs(differences.profit) ).reverse() log( @@ -233,12 +228,10 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( - (diff) => - diff.pastValue > 0.01 && - Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1% + (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 ), (investmentsData: PerContractInvestmentsData) => { - return investmentsData.difference > 0 + return investmentsData.profit > 0 } ) // pick 3 winning investments and 3 losing investments @@ -251,7 +244,9 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { worstInvestments.length === 0 && usersToContractsCreated[privateUser.id].length === 0 ) { - log('No bets in last week, no market movers, no markets created') + log( + 'No bets in last week, no market movers, no markets created. Not sending an email.' + ) await firestore.collection('private-users').doc(privateUser.id).update({ weeklyPortfolioUpdateEmailSent: true, }) @@ -268,7 +263,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { }) log('Sent weekly portfolio update email to', privateUser.email) count++ - log('sent out emails to user count:', count) + log('sent out emails to users:', count) }) ) } @@ -277,11 +272,10 @@ export type PerContractInvestmentsData = { questionTitle: string questionUrl: string questionProb: string - questionChange: string - questionChangeStyle: string + profitStyle: string currentValue: number pastValue: number - difference: number + profit: number } export type OverallPerformanceData = { diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index cb8c2fce..65a79c20 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -4,7 +4,6 @@ import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { ENV_CONFIG } from 'common/envs/constants' -import { useWindowSize } from 'web/hooks/use-window-size' import { Row } from './layout/row' import { AddFundsModal } from './add-funds-modal' @@ -36,23 +35,20 @@ export function AmountInput(props: { onChange(isInvalid ? undefined : amount) } - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 - const [addFundsModalOpen, setAddFundsModalOpen] = useState(false) return ( <> -