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 9b9c7b95..fb430067 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -63,6 +63,7 @@ export type Contract = { likedByUserIds?: string[] likedByUserCount?: number flaggedByUsernames?: string[] + openCommentBounties?: number } & 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/dev.ts b/common/envs/dev.ts index 96ec4dc2..ff3fd37d 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = { amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', // this is Phil's deployment twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', + sprigEnvironmentId: 'Tu7kRZPm7daP', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 3014f4e3..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -3,6 +3,7 @@ export type EnvConfig = { firebaseConfig: FirebaseConfig amplitudeApiKey?: string twitchBotEndpoint?: string + sprigEnvironmentId?: string // IDs for v2 cloud functions -- find these by deploying a cloud function and // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app @@ -40,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 = { @@ -56,6 +58,7 @@ type FirebaseConfig = { export const PROD_CONFIG: EnvConfig = { domain: 'manifold.markets', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', + sprigEnvironmentId: 'sQcrq9TDqkib', firebaseConfig: { apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', 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..d91dc300 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -116,8 +116,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 +159,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', diff --git a/common/post.ts b/common/post.ts index 05eab685..45503b22 100644 --- a/common/post.ts +++ b/common/post.ts @@ -9,4 +9,11 @@ export type Post = { slug: string } +export type DateDoc = Post & { + bounty: number + birthday: number + type: 'date-doc' + contractSlug: string +} + export const MAX_POST_TITLE_LENGTH = 480 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/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/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..72ceaf15 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' import { uniq } from 'lodash' +import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ export function getUrl(text: string) { @@ -103,6 +104,7 @@ export const exhibitExts = [ Mention, Iframe, TiptapTweet, + TiptapSpoiler, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts new file mode 100644 index 00000000..5502da58 --- /dev/null +++ b/common/util/tiptap-spoiler.ts @@ -0,0 +1,116 @@ +// adapted from @n8body/tiptap-spoiler + +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core' +import type { ElementType } from 'react' + +declare module '@tiptap/core' { + interface Commands { + spoilerEditor: { + setSpoiler: () => ReturnType + toggleSpoiler: () => ReturnType + unsetSpoiler: () => ReturnType + } + } +} + +export type SpoilerOptions = { + HTMLAttributes: Record + spoilerOpenClass: string + spoilerCloseClass?: string + inputRegex: RegExp + pasteRegex: RegExp + as: ElementType +} + +const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/ +const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g + +export const TiptapSpoiler = Mark.create({ + name: 'spoiler', + + inline: true, + group: 'inline', + inclusive: false, + exitable: true, + content: 'inline*', + + priority: 200, // higher priority than other formatting so they go inside + + addOptions() { + return { + HTMLAttributes: { 'aria-label': 'spoiler' }, + spoilerOpenClass: '', + spoilerCloseClass: undefined, + inputRegex: spoilerInputRegex, + pasteRegex: spoilerPasteRegex, + as: 'span', + editing: false, + } + }, + + addCommands() { + return { + setSpoiler: + () => + ({ commands }) => + commands.setMark(this.name), + toggleSpoiler: + () => + ({ commands }) => + commands.toggleMark(this.name), + unsetSpoiler: + () => + ({ commands }) => + commands.unsetMark(this.name), + } + }, + + addInputRules() { + return [ + markInputRule({ + find: this.options.inputRegex, + type: this.type, + }), + ] + }, + + addPasteRules() { + return [ + markPasteRule({ + find: this.options.pasteRegex, + type: this.type, + }), + ] + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (node) => + (node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + const elem = document.createElement(this.options.as as string) + + Object.entries( + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass, + }) + ).forEach(([attr, val]) => elem.setAttribute(attr, val)) + + elem.addEventListener('click', () => { + elem.setAttribute('class', this.options.spoilerOpenClass) + }) + + return elem + }, +}) diff --git a/docs/docs/api.md b/docs/docs/api.md index 007f6fa6..d25a18be 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -55,6 +55,7 @@ Returns the authenticated user. Gets all groups, in no particular order. Parameters: + - `availableToUserId`: Optional. if specified, only groups that the user can join and groups they've already joined will be returned. @@ -64,24 +65,23 @@ Requires no authorization. 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` Lists all markets, ordered by creation date descending. @@ -158,13 +158,16 @@ Requires no authorization. // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market url: string - outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC + outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC mechanism: string // dpm-2 or cpmm-1 probability: number pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool + min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value + max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value + isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability` volume: number volume7Days: number @@ -408,7 +411,7 @@ Requires no authorization. type FullMarket = LiteMarket & { bets: Bet[] comments: Comment[] - answers?: Answer[] + answers?: Answer[] // dpm-2 markets only description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json textDescription: string // string description without formatting, images, or embeds } @@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user. Parameters: -- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. +- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`. - `question`: Required. The headline question for the market. - `description`: Required. A long description describing the rules for the market. - Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json). @@ -569,6 +572,12 @@ For numeric markets, you must also provide: - `min`: The minimum value that the market may resolve to. - `max`: The maximum value that the market may resolve to. +- `isLogScale`: If true, your numeric market will increase exponentially from min to max. +- `initialValue`: An initial value for the market, between min and max, exclusive. + +For multiple choice markets, you must also provide: + +- `answers`: An array of strings, each of which will be a valid answer for the market. Example request: @@ -582,12 +591,17 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/add-liquidity` + +Adds a specified amount of liquidity into the market. + +- `amount`: Required. The amount of liquidity to add, in M$. ### `POST /v0/market/[marketId]/close` Closes a market on behalf of the authorized user. -- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. +- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. ### `POST /v0/market/[marketId]/resolve` @@ -600,15 +614,18 @@ For binary markets: - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `probabilityInt`: Optional. The probability to use for `MKT` resolution. -For free response markets: +For free response or multiple choice markets: - `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. -- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. +- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100. For numeric markets: - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `value`: The value that the market may resolves to. +- `probabilityInt`: Required if `value` is present. Should be equal to + - If log scale: `log10(value - min + 1) / log10(max - min + 1)` + - Otherwise: `(value - min) / (max - min)` Example request: @@ -752,6 +769,7 @@ Requires no authorization. ## Changelog +- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`) - 2022-07-15: Add user by username and user by ID APIs - 2022-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints diff --git a/firestore.rules b/firestore.rules index 26649fa6..bf0375e6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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/api.ts b/functions/src/api.ts index 7440f16a..7134c8d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -14,7 +14,7 @@ import { export { APIError } from '../../common/api' type Output = Record -type AuthedUser = { +export type AuthedUser = { uid: string creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) } 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-market.ts b/functions/src/create-market.ts index 300d91f2..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser, getContract, isProd } from './utils' -import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { @@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({ answers: z.string().trim().min(1).array().min(2), }) -export const createmarket = newEndpoint({}, async (req, auth) => { +export const createmarket = newEndpoint({}, (req, auth) => { + return createMarketHelper(req.body, auth) +}) + +export async function createMarketHelper(body: any, auth: AuthedUser) { const { question, description, @@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { outcomeType, groupId, visibility = 'public', - } = validate(bodySchema, req.body) + } = validate(bodySchema, body) let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue - ;({ min, max, initialValue, isLogScale } = validate( - numericSchema, - req.body - )) + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') @@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, req.body)) + ;({ initialProb } = validate(binarySchema, body)) } if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, req.body)) + ;({ answers } = validate(multipleChoiceSchema, body)) } const userDoc = await firestore.collection('users').doc(auth.uid).get() @@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => { // convert string descriptions into JSONContent const newDescription = - typeof description === 'string' + !description || typeof description === 'string' ? { type: 'doc', content: [ { type: 'paragraph', - content: [{ type: 'text', text: description }], + content: [{ type: 'text', text: description || ' ' }], }, ], } - : description ?? {} + : description const contract = getNewContract( contractRef.id, @@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } return contract -}) +} const getSlug = async (question: string) => { const proposedSlug = slugify(question) 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-post.ts b/functions/src/create-post.ts index 113a34bd..e9d6ae8f 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { createMarketHelper } from './create-market' +import { DAY_MS } from '../../common/util/time' const contentSchema: z.ZodType = z.lazy(() => z.intersection( @@ -35,11 +38,20 @@ const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, groupId: z.string().optional(), + + // Date doc fields: + bounty: z.number().optional(), + birthday: z.number().optional(), + type: z.string().optional(), + question: z.string().optional(), }) export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content, groupId } = validate(postSchema, req.body) + const { title, content, groupId, question, ...otherProps } = validate( + postSchema, + req.body + ) const creator = await getUser(auth.uid) if (!creator) @@ -51,14 +63,36 @@ export const createpost = newEndpoint({}, async (req, auth) => { const postRef = firestore.collection('posts').doc() - const post: Post = { + // If this is a date doc, create a market for it. + let contractSlug + if (question) { + const closeTime = Date.now() + DAY_MS * 30 * 3 + + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + auth + ) + contractSlug = result.slug + } + + const post: Post = removeUndefinedProps({ + ...otherProps, id: postRef.id, creatorId: creator.id, slug, title, createdTime: Date.now(), content: content, - } + contractSlug, + }) await postRef.create(post) if (groupId) { 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 dd91789a..993fac81 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -20,7 +20,7 @@ import { getNotificationDestinationsForUser } from '../../common/user-notificati import { PerContractInvestmentsData, OverallPerformanceData, -} from 'functions/src/weekly-portfolio-emails' +} from './weekly-portfolio-emails' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -643,8 +643,8 @@ 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`] = investment.questionChange - templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle + templateData[`question${i + 1}Change`] = formatMoney(investment.profit) + templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle }) await sendTemplateEmail( 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..d667f0d2 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,44 +1,118 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import { getUser, getValues, log } from './utils' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' +import { Txn } from '../../common/txn' +import { partition, sortBy } from 'lodash' +import { runTxn, TxnData } from './transact' +import * as admin from 'firebase-admin' 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 - ) + await handleUnusedCommentBountyRefunds(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 + ) +} + +async function handleUnusedCommentBountyRefunds(contract: Contract) { + const outstandingCommentBounties = await getValues( + firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') + ) + + const commentBountiesOnThisContract = sortBy( + outstandingCommentBounties.filter( + (bounty) => bounty.data?.contractId === contract.id + ), + (bounty) => bounty.createdTime + ) + + const [toBank, fromBank] = partition( + commentBountiesOnThisContract, + (bounty) => bounty.toType === 'BANK' + ) + if (toBank.length <= fromBank.length) return + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ openCommentBounties: 0 }) + + const refunds = toBank.slice(fromBank.length) + await Promise.all( + refunds.map(async (extraBountyTxn) => { + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: extraBountyTxn.toId, + fromType: 'BANK', + toId: extraBountyTxn.fromId, + toType: 'USER', + amount: extraBountyTxn.amount, + token: 'M$', + category: 'REFUND_COMMENT_BOUNTY', + data: { + contractId: contract.id, + }, + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log( + `Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`, + result.status + ) + log('message:', result.message) + } else { + log( + `Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`, + result.txn?.id + ) + } + }) + ) +} + +const firestore = admin.firestore() diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts new file mode 100644 index 00000000..99d5f12b --- /dev/null +++ b/functions/src/scripts/contest/bulk-add-liquidity.ts @@ -0,0 +1,52 @@ +// Run with `npx ts-node src/scripts/contest/resolve-markets.ts` + +const DOMAIN = 'http://localhost:3000' +// Dev API key for Cause Exploration Prizes (@CEP) +// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf' +// DEV API key for Criticism and Red Teaming (@CARTBot) +const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c' + +// Warning: Checking these in can be dangerous! +// Prod API key for @CEPBot + +// Can just curl /v0/group/{slug} to get a group +async function getGroupBySlug(slug: string) { + const resp = await fetch(`${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(`${DOMAIN}/api/v0/group/by-id/${id}/markets`) + return await resp.json() +} + +async function addLiquidityById(id: string, amount: number) { + const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + amount: amount, + }), + }) + return await resp.json() +} + +async function main() { + const group = await getGroupBySlug('cart-contest') + const markets = await getMarketsByGroupId(group.id) + + // Count up some metrics + console.log('Number of markets', markets.length) + + // Resolve each market to NO + for (const market of markets.slice(0, 3)) { + console.log(market.slug, market.totalLiquidity) + const resp = await addLiquidityById(market.id, 200) + console.log(resp) + } +} +main() diff --git a/functions/src/scripts/contest/create-markets.ts b/functions/src/scripts/contest/bulk-create-markets.ts similarity index 100% rename from functions/src/scripts/contest/create-markets.ts rename to functions/src/scripts/contest/bulk-create-markets.ts 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/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 b51c4217..70c7c742 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -17,7 +17,8 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' +import { Group } from '../../common/group' +import { batchedWaitAll } from '../../common/util/promise' const firestore = admin.firestore() @@ -27,28 +28,46 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues(firestore.collection('groups')), - ]) + console.log('Loading users') + const users = await getValues(firestore.collection('users')) + console.log('Loading contracts') + const contracts = await getValues(firestore.collection('contracts')) + + console.log('Loading portfolio history') + const allPortfolioHistories = await getValues( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ) + + console.log('Loading groups') + const groups = await getValues(firestore.collection('groups')) + + console.log('Loading bets') + const contractBets = await batchedWaitAll( + contracts + .filter((c) => c.id) + .map( + (c) => () => + getValues( + firestore.collection('contracts').doc(c.id).collection('bets') + ) + ), + 100 + ) + const bets = contractBets.flat() + + console.log('Loading group contracts') const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( + groups.map((group) => + getValues( firestore .collection('groups') .doc(group.id) .collection('groupContracts') ) - }) + ) ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` 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 dcbb68dd..bcf6da17 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -20,8 +20,8 @@ import { sendWeeklyPortfolioUpdateEmail } from './emails' import { contractUrl } from './utils' import { Txn } from '../../common/txn' import { formatMoney } from '../../common/util/format' +import { getContractBetMetrics } from '../../common/calculate' -// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week export const weeklyPortfolioUpdateEmails = functions .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) // every minute on Friday for an hour at 12pm PT (UTC -07:00) @@ -36,9 +36,9 @@ const firestore = admin.firestore() export async function sendPortfolioUpdateEmailsToAllUsers() { const privateUsers = isProd() ? // ian & stephen's ids - // ? filterDefined([ - // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), - // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), + // filterDefined([ + // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), + // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), // ]) await getAllPrivateUsers() : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) @@ -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) @@ -165,28 +166,43 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const bets = userBets.filter( (bet) => bet.contractId === contract.id ) + const previousBets = bets.filter( + (b) => b.createdTime < Date.now() - 7 * DAY_MS + ) + + const betsInLastWeek = bets.filter( + (b) => b.createdTime >= Date.now() - 7 * DAY_MS + ) const marketProbabilityAWeekAgo = cpmmContract.prob - cpmmContract.probChanges.week const currentMarketProbability = cpmmContract.resolutionProbability ? cpmmContract.resolutionProbability : cpmmContract.prob - const betsValueAWeekAgo = computeInvestmentValueCustomProb( - bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS), + + // TODO: returns 0 for resolved markets - doesn't include them + const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb( + previousBets, contract, marketProbabilityAWeekAgo ) - const currentBetsValue = computeInvestmentValueCustomProb( - bets, + const currentBetsMadeAWeekAgoValue = + computeInvestmentValueCustomProb( + previousBets, + contract, + currentMarketProbability + ) + const betsMadeInLastWeekProfit = getContractBetMetrics( contract, - currentMarketProbability - ) - const marketChange = - currentMarketProbability - marketProbabilityAWeekAgo + betsInLastWeek + ).profit + const profit = + betsMadeInLastWeekProfit + + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) return { - currentValue: currentBetsValue, - pastValue: betsValueAWeekAgo, - difference: currentBetsValue - betsValueAWeekAgo, + currentValue: currentBetsMadeAWeekAgoValue, + pastValue: betsMadeAWeekAgoValue, + profit, contractSlug: contract.slug, marketProbAWeekAgo: marketProbabilityAWeekAgo, questionTitle: contract.question, @@ -194,19 +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: ${ - currentMarketProbability > marketProbabilityAWeekAgo - ? 'rgba(0,160,0,1)' - : '#a80000' + profitStyle: `color: ${ + profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' };`, } as PerContractInvestmentsData }) ), - (differences) => Math.abs(differences.difference) + (differences) => Math.abs(differences.profit) ).reverse() log( @@ -218,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 @@ -236,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, }) @@ -253,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) }) ) } @@ -262,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 fbb49677..45c73c5e 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -5,7 +5,6 @@ import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' -import { useWindowSize } from 'web/hooks/use-window-size' import { Row } from './layout/row' export function AmountInput(props: { @@ -36,21 +35,18 @@ export function AmountInput(props: { onChange(isInvalid ? undefined : amount) } - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 - return ( <> -