diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..27a54149 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Run linter (remove unused imports) + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + +env: + FORCE_COLOR: 3 + NEXT_TELEMETRY_DISABLED: 1 + +# mqp - i generated a personal token to use for these writes -- it's unclear +# why, but the default token didn't work, even when i gave it max permissions + +jobs: + lint: + name: Auto-lint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + token: ${{ secrets.FORMATTER_ACCESS_TOKEN }} + - name: Restore cached node_modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} + - name: Install missing dependencies + run: yarn install --prefer-offline --frozen-lockfile + - name: Run lint script + run: yarn lint + - name: Commit any lint changes + if: always() + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Auto-remove unused imports + branch: ${{ github.head_ref }} diff --git a/common/.eslintrc.js b/common/.eslintrc.js index c6f9703e..5212207a 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: ['eslint:recommended'], ignorePatterns: ['lib'], env: { @@ -26,6 +26,7 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'unused-imports/no-unused-imports': 'error', }, }, ], diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts new file mode 100644 index 00000000..b27ac977 --- /dev/null +++ b/common/calculate-metrics.ts @@ -0,0 +1,158 @@ +import { last, sortBy, sum, sumBy } from 'lodash' +import { calculatePayout } from './calculate' +import { Bet } from './bet' +import { Contract } from './contract' +import { PortfolioMetrics, User } from './user' +import { DAY_MS } from './util/time' + +const computeInvestmentValue = ( + bets: Bet[], + contractsDict: { [k: string]: Contract } +) => { + return sumBy(bets, (bet) => { + const contract = contractsDict[bet.contractId] + if (!contract || contract.isResolved) return 0 + if (bet.sale || bet.isSold) return 0 + + const payout = calculatePayout(contract, bet, 'MKT') + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + +const computeTotalPool = (userContracts: Contract[], startTime = 0) => { + const periodFilteredContracts = userContracts.filter( + (contract) => contract.createdTime >= startTime + ) + return sum( + periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) + ) +} + +export const computeVolume = (contractBets: Bet[], since: number) => { + return sumBy(contractBets, (b) => + b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 + ) +} + +const calculateProbChangeSince = (descendingBets: Bet[], since: number) => { + const newestBet = descendingBets[0] + if (!newestBet) return 0 + + const betBeforeSince = descendingBets.find((b) => b.createdTime < since) + + if (!betBeforeSince) { + const oldestBet = last(descendingBets) ?? newestBet + return newestBet.probAfter - oldestBet.probBefore + } + + return newestBet.probAfter - betBeforeSince.probAfter +} + +export const calculateProbChanges = (descendingBets: Bet[]) => { + const now = Date.now() + const yesterday = now - DAY_MS + const weekAgo = now - 7 * DAY_MS + const monthAgo = now - 30 * DAY_MS + + return { + day: calculateProbChangeSince(descendingBets, yesterday), + week: calculateProbChangeSince(descendingBets, weekAgo), + month: calculateProbChangeSince(descendingBets, monthAgo), + } +} + +export const calculateCreatorVolume = (userContracts: Contract[]) => { + const allTimeCreatorVolume = computeTotalPool(userContracts, 0) + const monthlyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 30 * DAY_MS + ) + const weeklyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 7 * DAY_MS + ) + + const dailyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 1 * DAY_MS + ) + + return { + daily: dailyCreatorVolume, + weekly: weeklyCreatorVolume, + monthly: monthlyCreatorVolume, + allTime: allTimeCreatorVolume, + } +} + +export const calculateNewPortfolioMetrics = ( + user: User, + contractsById: { [k: string]: Contract }, + currentBets: Bet[] +) => { + const investmentValue = computeInvestmentValue(currentBets, contractsById) + const newPortfolio = { + investmentValue: investmentValue, + balance: user.balance, + totalDeposits: user.totalDeposits, + timestamp: Date.now(), + userId: user.id, + } + return newPortfolio +} + +const calculateProfitForPeriod = ( + startTime: number, + descendingPortfolio: PortfolioMetrics[], + currentProfit: number +) => { + const startingPortfolio = descendingPortfolio.find( + (p) => p.timestamp < startTime + ) + + if (startingPortfolio === undefined) { + return currentProfit + } + + const startingProfit = calculatePortfolioProfit(startingPortfolio) + + return currentProfit - startingProfit +} + +export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { + return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits +} + +export const calculateNewProfit = ( + portfolioHistory: PortfolioMetrics[], + newPortfolio: PortfolioMetrics +) => { + const allTimeProfit = calculatePortfolioProfit(newPortfolio) + const descendingPortfolio = sortBy( + portfolioHistory, + (p) => p.timestamp + ).reverse() + + const newProfit = { + daily: calculateProfitForPeriod( + Date.now() - 1 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + weekly: calculateProfitForPeriod( + Date.now() - 7 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + monthly: calculateProfitForPeriod( + Date.now() - 30 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + allTime: allTimeProfit, + } + + return newProfit +} diff --git a/common/calculate.ts b/common/calculate.ts index 758fc3cd..da4ce13a 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) { const sortedBets = sortBy(yourBets, 'createdTime') for (const bet of sortedBets) { const { outcome, shares, amount } = bet + if (floatingEqual(shares, 0)) continue + if (amount > 0) { totalShares[outcome] = (totalShares[outcome] ?? 0) + shares totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount diff --git a/common/comment.ts b/common/comment.ts index c7f9b855..7ecbb6d4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,6 +1,6 @@ import type { JSONContent } from '@tiptap/core' -export type AnyCommentType = OnContract | OnGroup +export type AnyCommentType = OnContract | OnGroup | OnPost // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. @@ -20,19 +20,31 @@ export type Comment = { userAvatarUrl?: string } & T -type OnContract = { +export type OnContract = { commentType: 'contract' contractId: string - contractSlug: string - contractQuestion: string answerOutcome?: string betId?: string + + // denormalized from contract + contractSlug: string + contractQuestion: string + + // denormalized from bet + betAmount?: number + betOutcome?: string } -type OnGroup = { +export type OnGroup = { commentType: 'group' groupId: string } +export type OnPost = { + commentType: 'post' + postId: string +} + export type ContractComment = Comment export type GroupComment = Comment +export type PostComment = Comment diff --git a/common/contract-details.ts b/common/contract-details.ts index 02af6359..c231b1e4 100644 --- a/common/contract-details.ts +++ b/common/contract-details.ts @@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) { export function contractTextDetails(contract: Contract) { // eslint-disable-next-line @typescript-eslint/no-var-requires const dayjs = require('dayjs') - const { closeTime, tags } = contract + const { closeTime, groupLinks } = contract const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - const hashtags = tags.map((tag) => `#${tag}`) + const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`) return ( `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + @@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) { ).format('MMM D, h:mma')}` : '') + ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + (groupHashtags ? ` • ${groupHashtags.join(' ')}` : '') ) } @@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => { creatorAvatarUrl, description, numericValue, + resolution, } } @@ -103,6 +104,7 @@ export type OgCardProps = { creatorUsername: string creatorAvatarUrl?: string numericValue?: string + resolution?: string } export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { @@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { creatorOutcome, acceptorOutcome, } = challenge || {} + const { + probability, + numericValue, + resolution, + creatorAvatarUrl, + question, + metadata, + creatorUsername, + creatorName, + } = props const { userName, userAvatarUrl } = acceptances?.[0] ?? {} const probabilityParam = - props.probability === undefined + probability === undefined ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` + : `&probability=${encodeURIComponent(probability ?? '')}` const numericValueParam = - props.numericValue === undefined + numericValue === undefined ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + : `&numericValue=${encodeURIComponent(numericValue ?? '')}` const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined + creatorAvatarUrl === undefined ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + : `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}` const challengeUrlParams = challenge ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + @@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` : '' + const resolutionUrlParam = resolution + ? `&resolution=${encodeURIComponent(resolution)}` + : '' + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + + `?question=${encodeURIComponent(question)}` + probabilityParam + numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + + `&metadata=${encodeURIComponent(metadata)}` + + `&creatorName=${encodeURIComponent(creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams + `&creatorUsername=${encodeURIComponent(creatorUsername)}` + + challengeUrlParams + + resolutionUrlParam ) } diff --git a/common/contract.ts b/common/contract.ts index 2b330201..0d2a38ca 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -59,6 +59,8 @@ export type Contract = { popularityScore?: number followerCount?: number featuredOnHomeRank?: number + likedByUserIds?: string[] + likedByUserCount?: number } & T export type BinaryContract = Contract & Binary @@ -85,6 +87,12 @@ export type CPMM = { pool: { [outcome: string]: number } p: number // probability constant in y^p * n^(1-p) = k totalLiquidity: number // in M$ + prob: number + probChanges: { + day: number + week: number + month: number + } } export type Binary = { diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 89d040e8..ba460d58 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' +export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace( + /-/g, + '_' +)}` + // Manifold's domain or any subdomains thereof export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' 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 7d3215ae..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -6,12 +6,11 @@ export type Group = { creatorId: string // User id createdTime: number mostRecentActivityTime: number - memberIds: string[] // User ids anyoneCanJoin: boolean - contractIds: string[] - + totalContracts: number + totalMembers: number + aboutPostId?: string chatDisabled?: boolean - mostRecentChatActivityTime?: number mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 diff --git a/common/like.ts b/common/like.ts new file mode 100644 index 00000000..38b25dad --- /dev/null +++ b/common/like.ts @@ -0,0 +1,8 @@ +export type Like = { + id: string // will be id of the object liked, i.e. contract.id + userId: string + type: 'contract' + createdTime: number + tipTxnId?: string // only holds most recent tip txn id +} +export const LIKE_TIP_AMOUNT = 5 diff --git a/common/loans.ts b/common/loans.ts index cb956c09..e05f1c2a 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -10,7 +10,7 @@ import { import { PortfolioMetrics, User } from './user' import { filterDefined } from './util/array' -const LOAN_DAILY_RATE = 0.01 +const LOAN_DAILY_RATE = 0.02 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal @@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = ( contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { - const openBets = bets.filter((bet) => bet.isSold || bet.sale) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) return openBets.map((bet) => { const loanAmount = bet.loanAmount ?? 0 diff --git a/common/new-contract.ts b/common/new-contract.ts index 17b872ab..431f435e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { initialProbability: p, p, pool: pool, + prob: initialProb, + probChanges: { day: 0, week: 0, month: 0 }, } return system diff --git a/common/notification.ts b/common/notification.ts index f10bd3f6..9ec320fa 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -15,6 +15,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string + data?: string sourceContractTitle?: string sourceContractCreatorUsername?: string @@ -40,6 +41,8 @@ export type notification_source_types = | 'challenge' | 'betting_streak_bonus' | 'loan' + | 'like' + | 'tip_and_like' export type notification_source_update_types = | 'created' @@ -71,3 +74,5 @@ export type notification_reason_types = | 'betting_streak_incremented' | 'loan_income' | 'you_follow_contract' + | 'liked_your_contract' + | 'liked_and_tipped_your_contract' diff --git a/common/package.json b/common/package.json index 955e9662..52195398 100644 --- a/common/package.json +++ b/common/package.json @@ -8,11 +8,11 @@ }, "sideEffects": false, "dependencies": { - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/post.ts b/common/post.ts new file mode 100644 index 00000000..05eab685 --- /dev/null +++ b/common/post.ts @@ -0,0 +1,12 @@ +import { JSONContent } from '@tiptap/core' + +export type Post = { + id: string + title: string + content: JSONContent + creatorId: string // User id + createdTime: number + slug: string +} + +export const MAX_POST_TITLE_LENGTH = 480 diff --git a/common/user.ts b/common/user.ts index 48a3d59c..0e333278 100644 --- a/common/user.ts +++ b/common/user.ts @@ -34,6 +34,7 @@ export type User = { followerCountCached: number followedCategories?: string[] + homeSections?: { visible: string[]; hidden: string[] } referredByUserId?: string referredByContractId?: string @@ -44,6 +45,7 @@ export type User = { currentBettingStreak?: number hasSeenContractFollowModal?: boolean freeMarketsCreated?: number + isBannedFromPosting?: boolean } export type PrivateUser = { diff --git a/common/util/object.ts b/common/util/object.ts index 5596286e..41d2cd70 100644 --- a/common/util/object.ts +++ b/common/util/object.ts @@ -1,6 +1,6 @@ import { union } from 'lodash' -export const removeUndefinedProps = (obj: T): T => { +export const removeUndefinedProps = (obj: T): T => { const newObj: any = {} for (const key of Object.keys(obj)) { @@ -37,4 +37,3 @@ export const subtractObjects = ( return newObj as T } - diff --git a/common/util/time.ts b/common/util/time.ts index 914949e4..9afb8db4 100644 --- a/common/util/time.ts +++ b/common/util/time.ts @@ -1,2 +1,3 @@ -export const HOUR_MS = 60 * 60 * 1000 +export const MINUTE_MS = 60 * 1000 +export const HOUR_MS = 60 * MINUTE_MS export const DAY_MS = 24 * HOUR_MS diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts index 5af63d2f..9e260821 100644 --- a/common/util/tiptap-iframe.ts +++ b/common/util/tiptap-iframe.ts @@ -35,7 +35,7 @@ export default Node.create({ HTMLAttributes: { class: 'iframe-wrapper' + ' ' + wrapperClasses, // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: - style: 'padding-bottom: 20rem;', + style: 'padding-bottom: 20rem; ', }, } }, @@ -48,6 +48,9 @@ export default Node.create({ frameborder: { default: 0, }, + height: { + default: 0, + }, allowfullscreen: { default: this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen, @@ -60,6 +63,11 @@ export default Node.create({ }, renderHTML({ HTMLAttributes }) { + this.options.HTMLAttributes.style = + this.options.HTMLAttributes.style + + ' height: ' + + HTMLAttributes.height + + ';' return [ 'div', this.options.HTMLAttributes, diff --git a/docs/docs/api.md b/docs/docs/api.md index c02a5141..e284abdf 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -54,6 +54,10 @@ 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. + Requires no authorization. ### `GET /v0/groups/[slug]` @@ -62,12 +66,18 @@ Gets a group by its slug. Requires no authorization. -### `GET /v0/groups/by-id/[id]` +### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. Requires no authorization. +### `GET /v0/group/by-id/[id]/markets` + +Gets a group's markets by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 458b81ee..7a30fed6 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets. ## API / Dev - [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API + - [PyManifold fork](https://github.com/gappleto97/PyManifold/) - Fork maintained by [@LivInTheLookingGlass](https://manifold.markets/LivInTheLookingGlass) - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets @@ -24,3 +25,16 @@ A list of community-created projects built on, or related to, Manifold Markets. - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon - [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets +- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae) + +## Writeups +- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander +- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki +- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania +- [What I learned about running a betting market game night contest](https://shakeddown.wordpress.com/2022/08/04/what-i-learned-about-running-a-betting-market-game-night-contest/) by shakeddown +- [Free-riding on prediction markets](https://pedunculate.substack.com/p/free-riding-on-prediction-markets) by John Roxton + +## Art + +- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png) +- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) diff --git a/firestore.indexes.json b/firestore.indexes.json index 874344be..bcee41d5 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -22,6 +22,20 @@ } ] }, + { + "collectionGroup": "bets", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "isFilled", + "order": "ASCENDING" + }, + { + "fieldPath": "userId", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "bets", "queryScope": "COLLECTION_GROUP", @@ -36,6 +50,70 @@ } ] }, + { + "collectionGroup": "bets", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isCancelled", + "order": "ASCENDING" + }, + { + "fieldPath": "isFilled", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "challenges", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "creatorId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "commentType", + "order": "ASCENDING" + }, + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "comments", "queryScope": "COLLECTION_GROUP", @@ -78,6 +156,42 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "creatorId", + "order": "ASCENDING" + }, + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "groupSlugs", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -124,6 +238,46 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "closeTime", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", diff --git a/firestore.rules b/firestore.rules index 4cd718d3..9a72e454 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,7 +12,9 @@ service cloud.firestore { 'taowell@gmail.com', 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com', - 'iansphilips@gmail.com' + 'iansphilips@gmail.com', + 'd4vidchee@gmail.com', + 'federicoruizcassarino@gmail.com' ] } @@ -62,6 +64,11 @@ service cloud.firestore { allow write: if request.auth.uid == userId; } + match /users/{userId}/likes/{likeId} { + allow read; + allow write: if request.auth.uid == userId; + } + match /{somePath=**}/follows/{followUserId} { allow read; } @@ -155,25 +162,53 @@ service cloud.firestore { .hasOnly(['isSeen', 'viewTime']); } - match /groups/{groupId} { + match /{somePath=**}/groupMembers/{memberId} { + allow read; + } + + match /{somePath=**}/groupContracts/{contractId} { + allow read; + } + + match /groups/{groupId} { allow read; - allow update: if request.auth.uid == resource.data.creatorId + allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]); - allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin) - && request.resource.data.diff(resource.data) - .affectedKeys() - .hasOnly([ 'contractIds', 'memberIds' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); allow delete: if request.auth.uid == resource.data.creatorId; - function isMember() { - return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; + match /groupContracts/{contractId} { + allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId + } + + 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; + } + + function isGroupMember() { + return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid)); } + match /comments/{commentId} { allow read; - allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember(); } + } + + match /posts/{postId} { + allow read; + allow update: if isAdmin() || request.auth.uid == resource.data.creatorId + && request.resource.data.diff(resource.data) + .affectedKeys() + .hasOnly(['name', 'content']); + allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; + match /comments/{commentId} { + allow read; + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ; + } + } } } diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 2c607231..55070858 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: ['eslint:recommended'], ignorePatterns: ['dist', 'lib'], env: { @@ -26,6 +26,7 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'unused-imports/no-unused-imports': 'error', }, }, ], diff --git a/functions/package.json b/functions/package.json index c8f295fc..d5a578de 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,11 +26,11 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "cors": "2.8.5", "dayjs": "1.11.4", "express": "4.18.1", diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 71c6bd64..fc64aeff 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => { createdTime: Date.now(), mostRecentActivityTime: Date.now(), // TODO: allow users to add contract ids on group creation - contractIds: [], anyoneCanJoin, - memberIds, + totalContracts: 0, + totalMembers: memberIds.length, } await groupRef.create(group) + // create a GroupMemberDoc for each member + await Promise.all( + memberIds.map((memberId) => + groupRef.collection('groupMembers').doc(memberId).create({ + userId: memberId, + createdTime: Date.now(), + }) + ) + ) + return { status: 'success', group: group } }) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index e9804f90..300d91f2 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } group = groupDoc.data() as Group + const groupMembersSnap = await firestore + .collection(`groups/${groupId}/groupMembers`) + .get() + const groupMemberDocs = groupMembersSnap.docs.map( + (doc) => doc.data() as { userId: string; createdTime: number } + ) if ( - !group.memberIds.includes(user.id) && + !groupMemberDocs.map((m) => m.userId).includes(user.id) && !group.anyoneCanJoin && group.creatorId !== user.id ) { @@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => { await contractRef.create(contract) if (group != null) { - if (!group.contractIds.includes(contractRef.id)) { + const groupContractsSnap = await firestore + .collection(`groups/${groupId}/groupContracts`) + .get() + const groupContracts = groupContractsSnap.docs.map( + (doc) => doc.data() as { contractId: string; createdTime: number } + ) + if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { await createGroupLinks(group, [contractRef.id], auth.uid) - const groupDocRef = firestore.collection('groups').doc(group.id) - groupDocRef.update({ - contractIds: uniq([...group.contractIds, contractRef.id]), + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 035126c5..131d6e85 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' import { richTextToString } from '../../common/util/parse' +import { Like } from '../../common/like' const firestore = admin.firestore() type user_to_reason_texts = { @@ -150,15 +151,6 @@ export const createNotification = async ( } } - const notifyContractCreatorOfUniqueBettorsBonus = async ( - userToReasonTexts: user_to_reason_texts, - userId: string - ) => { - userToReasonTexts[userId] = { - reason: 'unique_bettors_on_your_contract', - } - } - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -191,16 +183,6 @@ export const createNotification = async ( sourceContract ) { await notifyContractCreator(userToReasonTexts, sourceContract) - } else if ( - sourceType === 'bonus' && - sourceUpdateType === 'created' && - sourceContract - ) { - // Note: the daily bonus won't have a contract attached to it - await notifyContractCreatorOfUniqueBettorsBonus( - userToReasonTexts, - sourceContract.creatorId - ) } await createUsersNotifications(userToReasonTexts) @@ -689,3 +671,85 @@ export const createBettingStreakBonusNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createLikeNotification = async ( + fromUser: User, + toUser: User, + like: Like, + idempotencyKey: string, + contract: Contract, + tip?: TipTxn +) => { + 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', + createdTime: Date.now(), + isSeen: false, + sourceId: like.id, + sourceType: tip ? 'tip_and_like' : 'like', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: tip?.amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: contract.slug, + 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)) +} + +export const createUniqueBettorBonusNotification = async ( + contractCreatorId: string, + bettor: User, + txnId: string, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts new file mode 100644 index 00000000..40d39bba --- /dev/null +++ b/functions/src/create-post.ts @@ -0,0 +1,83 @@ +import * as admin from 'firebase-admin' + +import { getUser } from './utils' +import { slugify } from '../../common/util/slugify' +import { randomString } from '../../common/util/random' +import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' + +const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const postSchema = z.object({ + title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), + content: contentSchema, +}) + +export const createpost = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { title, content } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + if (!creator) + throw new APIError(400, 'No user exists with the authenticated user ID.') + + console.log('creating post owned by', creator.username, 'titled', title) + + const slug = await getSlug(title) + + const postRef = firestore.collection('posts').doc() + + const post: Post = { + id: postRef.id, + creatorId: creator.id, + slug, + title, + createdTime: Date.now(), + content: content, + } + + await postRef.create(post) + + return { status: 'success', post } +}) + +export const getSlug = async (title: string) => { + const proposedSlug = slugify(title) + + const preexistingPost = await getPostFromSlug(proposedSlug) + + return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug +} + +export async function getPostFromSlug(slug: string) { + const firestore = admin.firestore() + const snap = await firestore + .collection('posts') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Post) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 35394e90..eabe0fd0 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,5 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { uniq } from 'lodash' import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' @@ -17,7 +16,7 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' -import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' +import { Group } from '../../common/group' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ @@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => { firestore.collection('groups').where('slug', '==', slug) ) await firestore - .collection('groups') - .doc(groups[0].id) - .update({ - memberIds: uniq(groups[0].memberIds.concat(user.id)), - }) - } - - for (const slug of NEW_USER_GROUP_SLUGS) { - const groups = await getValues( - firestore.collection('groups').where('slug', '==', slug) - ) - const group = groups[0] - await firestore - .collection('groups') - .doc(group.id) - .update({ - memberIds: uniq(group.memberIds.concat(user.id)), - }) + .collection(`groups/${groups[0].id}/groupMembers`) + .doc(user.id) + .set({ userId: user.id, createdTime: Date.now() }) } } diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 225436ad..1f7fa5fa 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -1,84 +1,91 @@ - - - - - Market answer + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - -
+ - Manifold Markets -
+ - +
- + - - - + {{name}} + + + + - - - + {{answer}} + + + + - - -
+ -
- +
+ - {{name}} -
-
+ -
+
- {{answer}} -
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 711f7ccb..fa44c1d5 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -1,84 +1,91 @@ - - - - - Market closed + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - - + You asked + + + - - - + {{question}} + + + - - - + Market closed + + + + - - -
+ - Manifold Markets -
+ - You asked -
+ - + - {{question}} -
+ -

+

- Market closed -

-
+ - +
- + - - - + + + - - -
+ - Hi {{name}}, -
+ Hi {{name}}, +
-
+
- A market you created has closed. It's attracted - {{volume}} - in bets — congrats! -
+ A market you created has closed. It's attracted + {{volume}} + in bets — congrats! +
-
+
- Resolve your market to earn {{creatorFee}} as the - creator commission. -
+ Please resolve your market. +
-
+
- Thanks, -
+ Thanks, +
- Manifold Team -
+ Manifold Team +
-
+
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 84118964..0b5b9a54 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -1,84 +1,91 @@ - - - - - Market comment + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - -
+ - Manifold Markets -
+ - +
- + - - - + {{commentorName}} + {{betDescription}} + + + + - - - + {{comment}} + + + + - - -
+ -
- +
+ - {{commentorName}} - {{betDescription}} -
-
+ -
+
- {{comment}} -
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ 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 e8b090b5..c1ff3beb 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -1,84 +1,91 @@ - - - - - Market resolved + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - - + {{creatorName}} asked + + + - - - + {{question}} + + + - - - + Resolved {{outcome}} + + + + - - -
+ - Manifold Markets -
+ - {{creatorName}} asked -
+ - + - {{question}} -
+ -

+

- Resolved {{outcome}} -

-
+ - +
- + - - - + + + - - -
+ - Dear {{name}}, -
+ Dear {{name}}, +
-
+
- A market you bet in has been resolved! -
+ A market you bet in has been resolved! +
-
+
- Your investment was - M$ {{investment}}. -
+ Your investment was + {{investment}}. +
-
+
- Your payout is - M$ {{payout}}. -
+ Your payout is + {{payout}}. +
-
+
- Thanks, -
+ Thanks, +
- Manifold Team -
+ Manifold Team +
-
+
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index e6e52090..2c9c6f12 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -4,7 +4,6 @@ import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' import { formatLargeNumber, @@ -53,22 +52,29 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` - // const creatorPayoutText = - // userId === creator.id - // ? ` (plus ${formatMoney(creatorPayout)} in commissions)` - // : '' + const creatorPayoutText = + creatorPayout >= 1 && userId === creator.id + ? ` (plus ${formatMoney(creatorPayout)} in commissions)` + : '' const emailType = 'market-resolved' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const displayedInvestment = + Number.isNaN(investment) || investment < 0 + ? formatMoney(0) + : formatMoney(investment) + + const displayedPayout = formatMoney(payout) + const templateData: market_resolved_template = { userId: user.id, name: user.name, creatorName: creator.name, question: contract.question, outcome, - investment: `${Math.floor(investment)}`, - payout: `${Math.floor(payout)}`, + investment: displayedInvestment, + payout: displayedPayout + creatorPayoutText, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, unsubscribeUrl, } @@ -180,7 +186,7 @@ export const sendPersonalFollowupEmail = async ( const emailBody = `Hi ${firstName}, -Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far? +Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far? If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh). @@ -315,7 +321,7 @@ export const sendMarketCloseEmail = async ( const { username, name, id: userId } = user const firstName = name.split(' ')[0] - const { question, slug, volume, mechanism, collectedFees } = contract + const { question, slug, volume } = contract const url = `https://${DOMAIN}/${username}/${slug}` const emailType = 'market-resolve' @@ -332,10 +338,6 @@ export const sendMarketCloseEmail = async ( userId, name: firstName, volume: formatMoney(volume), - creatorFee: - mechanism === 'dpm-2' - ? `${DPM_CREATOR_FEE * 100}% of the profits` - : formatMoney(collectedFees.creatorFee), } ) } diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts deleted file mode 100644 index 4aaaac11..00000000 --- a/functions/src/get-custom-token.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as admin from 'firebase-admin' -import { - APIError, - EndpointDefinition, - lookupUser, - parseCredentials, - writeResponseError, -} from './api' - -const opts = { - method: 'GET', - minInstances: 1, - concurrency: 100, - memory: '2GiB', - cpu: 1, -} as const - -export const getcustomtoken: EndpointDefinition = { - opts, - handler: async (req, res) => { - try { - const credentials = await parseCredentials(req) - if (credentials.kind != 'jwt') { - throw new APIError(403, 'API keys cannot mint custom tokens.') - } - const user = await lookupUser(credentials) - const token = await admin.auth().createCustomToken(user.uid) - res.status(200).json({ token: token }) - } catch (e) { - writeResponseError(e, res) - } - }, -} diff --git a/functions/src/index.ts b/functions/src/index.ts index 012ba241..be73b6af 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,9 +21,7 @@ export * from './on-follow-user' export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' -export * from './on-create-group' export * from './on-update-user' -export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' @@ -31,6 +29,7 @@ export * from './weekly-markets-emails' export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' export * from './on-update-contract-follow' +export * from './on-update-like' // v2 export * from './health' @@ -71,7 +70,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' -import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -96,7 +95,7 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) -const getCustomTokenFunction = toCloudFunction(getcustomtoken) +const createPostFunction = toCloudFunction(createpost) export { healthFunction as health, @@ -119,5 +118,5 @@ export { createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, - getCustomTokenFunction as getcustomtoken, + createPostFunction as createpost, } diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index ff6cf9d9..5dbebfc3 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, createBettingStreakBonusNotification, - createNotification, + createUniqueBettorBonusNotification, } from './create-notification' import { filterDefined } from '../../common/util/array' import { Contract } from '../../common/contract' @@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore log(`Could not find contract ${contractId}`) return } - await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) const bettor = await getUser(bet.userId) if (!bettor) return + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor) await updateBettingStreak(bettor, bet, contract, eventId) @@ -126,7 +126,7 @@ const updateBettingStreak = async ( const updateUniqueBettorsAndGiveCreatorBonus = async ( contract: Contract, eventId: string, - bettorId: string + bettor: User ) => { let previousUniqueBettorIds = contract.uniqueBettorIds @@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) - const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) + const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) // Update contract unique bettors if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) - isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) + isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, @@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } // No need to give a bonus for the creator's bet - if (!isNewUniqueBettor || bettorId == contract.creatorId) return + if (!isNewUniqueBettor || bettor.id == contract.creatorId) return // Create combined txn for all new unique bettors const bonusTxnDetails = { @@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) } else { log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) - await createNotification( + await createUniqueBettorBonusNotification( + contract.creatorId, + bettor, result.txn.id, - 'bonus', - 'created', - fromUser, - eventId + '-bonus', - result.txn.amount + '', - { - contract, - slug: contract.slug, - title: contract.question, - } + contract, + result.txn.amount, + eventId + '-unique-bettor-bonus' ) } } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 8651bde0..a36a8bca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -6,7 +6,10 @@ import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { + createCommentOrAnswerOrUpdatedContractNotification, + filterUserIdsForOnlyFollowerIds, +} from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -60,11 +63,15 @@ export const onCreateCommentOnContract = functions .doc(comment.betId) .get() bet = betSnapshot.data() as Bet - answer = contract.outcomeType === 'FREE_RESPONSE' && contract.answers ? contract.answers.find((answer) => answer.id === bet?.outcome) : undefined + + await change.ref.update({ + betOutcome: bet.outcome, + betAmount: bet.amount, + }) } const comments = await getValues( @@ -95,10 +102,13 @@ export const onCreateCommentOnContract = functions } ) - const recipientUserIds = uniq([ - contract.creatorId, - ...comments.map((comment) => comment.userId), - ]).filter((id) => id !== comment.userId) + const recipientUserIds = await filterUserIdsForOnlyFollowerIds( + uniq([ + contract.creatorId, + ...comments.map((comment) => comment.userId), + ]).filter((id) => id !== comment.userId), + contractId + ) await Promise.all( recipientUserIds.map((userId) => diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts deleted file mode 100644 index 15f2bbc1..00000000 --- a/functions/src/on-create-comment-on-group.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as functions from 'firebase-functions' -import { GroupComment } from '../../common/comment' -import * as admin from 'firebase-admin' -import { Group } from '../../common/group' -import { User } from '../../common/user' -import { createGroupCommentNotification } from './create-notification' -const firestore = admin.firestore() - -export const onCreateCommentOnGroup = functions.firestore - .document('groups/{groupId}/comments/{commentId}') - .onCreate(async (change, context) => { - const { eventId } = context - const { groupId } = context.params as { - groupId: string - } - - const comment = change.data() as GroupComment - const creatorSnapshot = await firestore - .collection('users') - .doc(comment.userId) - .get() - if (!creatorSnapshot.exists) throw new Error('Could not find user') - - const groupSnapshot = await firestore - .collection('groups') - .doc(groupId) - .get() - if (!groupSnapshot.exists) throw new Error('Could not find group') - - const group = groupSnapshot.data() as Group - await firestore.collection('groups').doc(groupId).update({ - mostRecentChatActivityTime: comment.createdTime, - }) - - await Promise.all( - group.memberIds.map(async (memberId) => { - return await createGroupCommentNotification( - creatorSnapshot.data() as User, - memberId, - comment, - group, - eventId - ) - }) - ) - }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts deleted file mode 100644 index 5209788d..00000000 --- a/functions/src/on-create-group.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createNotification } from './create-notification' -import { Group } from '../../common/group' - -export const onCreateGroup = functions.firestore - .document('groups/{groupId}') - .onCreate(async (change, context) => { - const group = change.data() as Group - const { eventId } = context - - const groupCreator = await getUser(group.creatorId) - if (!groupCreator) throw new Error('Could not find group creator') - // create notifications for all members of the group - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - recipients: group.memberIds, - slug: group.slug, - title: group.name, - } - ) - }) diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts index f7d54fe8..20ef8e30 100644 --- a/functions/src/on-update-contract-follow.ts +++ b/functions/src/on-update-contract-follow.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { FieldValue } from 'firebase-admin/firestore' +// TODO: should cache the follower user ids in the contract as these triggers aren't idempotent export const onDeleteContractFollow = functions.firestore .document('contracts/{contractId}/follows/{userId}') .onDelete(async (change, context) => { diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 7e6a5697..93fb5550 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - if (prevGroup.contractIds.length < group.contractIds.length) { - await firestore - .collection('groups') - .doc(group.id) - .update({ mostRecentContractAddedTime: Date.now() }) - //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url - // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two - } - await firestore .collection('groups') .doc(group.id) .update({ mostRecentActivityTime: Date.now() }) }) +export const onCreateGroupContract = functions.firestore + .document('groups/{groupId}/groupContracts/{contractId}') + .onCreate(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentContractAddedTime: Date.now(), + totalContracts: admin.firestore.FieldValue.increment(1), + }) + }) + +export const onDeleteGroupContract = functions.firestore + .document('groups/{groupId}/groupContracts/{contractId}') + .onDelete(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentContractAddedTime: Date.now(), + totalContracts: admin.firestore.FieldValue.increment(-1), + }) + }) + +export const onCreateGroupMember = functions.firestore + .document('groups/{groupId}/groupMembers/{memberId}') + .onCreate(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentActivityTime: Date.now(), + totalMembers: admin.firestore.FieldValue.increment(1), + }) + }) + +export const onDeleteGroupMember = functions.firestore + .document('groups/{groupId}/groupMembers/{memberId}') + .onDelete(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentActivityTime: Date.now(), + totalMembers: admin.firestore.FieldValue.increment(-1), + }) + }) + export async function removeGroupLinks(group: Group, contractIds: string[]) { for (const contractId of contractIds) { const contract = await getContract(contractId) diff --git a/functions/src/on-update-like.ts b/functions/src/on-update-like.ts new file mode 100644 index 00000000..7633c395 --- /dev/null +++ b/functions/src/on-update-like.ts @@ -0,0 +1,109 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Like } from '../../common/like' +import { getContract, getUser, log } from './utils' +import { createLikeNotification } from './create-notification' +import { TipTxn } from '../../common/txn' +import { uniq } from 'lodash' + +const firestore = admin.firestore() + +export const onCreateLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onCreate(async (change, context) => { + const like = change.data() as Like + const { eventId } = context + if (like.type === 'contract') { + await handleCreateLikeNotification(like, eventId) + await updateContractLikes(like) + } + }) + +export const onUpdateLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onUpdate(async (change, context) => { + const like = change.after.data() as Like + const prevLike = change.before.data() as Like + const { eventId } = context + if (like.type === 'contract' && like.tipTxnId !== prevLike.tipTxnId) { + await handleCreateLikeNotification(like, eventId) + await updateContractLikes(like) + } + }) + +export const onDeleteLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onDelete(async (change) => { + const like = change.data() as Like + if (like.type === 'contract') { + await removeContractLike(like) + } + }) + +const updateContractLikes = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq( + (contract.likedByUserIds ?? []).concat(like.userId) + ) + await firestore + .collection('contracts') + .doc(like.id) + .update({ likedByUserIds, likedByUserCount: likedByUserIds.length }) +} + +const handleCreateLikeNotification = async (like: Like, eventId: string) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const contractCreator = await getUser(contract.creatorId) + if (!contractCreator) { + log('Could not find contract creator') + return + } + const liker = await getUser(like.userId) + if (!liker) { + log('Could not find liker') + return + } + let tipTxnData = undefined + + if (like.tipTxnId) { + const tipTxn = await firestore.collection('txns').doc(like.tipTxnId).get() + if (!tipTxn.exists) { + log('Could not find tip txn') + return + } + tipTxnData = tipTxn.data() as TipTxn + } + + await createLikeNotification( + liker, + contractCreator, + like, + eventId, + contract, + tipTxnData + ) +} + +const removeContractLike = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + const newLikedByUserIds = likedByUserIds.filter( + (userId) => userId !== like.userId + ) + await firestore.collection('contracts').doc(like.id).update({ + likedByUserIds: newLikedByUserIds, + likedByUserCount: newLikedByUserIds.length, + }) +} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 404fda50..d98430c1 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { !isFinite(newP) || Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) ) { - throw new APIError(400, 'Bet too large for current liquidity pool.') + throw new APIError(400, 'Trade too large for current liquidity pool.') } const betDoc = contractDoc.collection('bets').doc() diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 0a69521f..08cc16f2 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' import { User } from '../../common/user' +import { floatingEqual } from '../../common/util/math' export const redeemShares = async (userId: string, contractId: string) => { return await firestore.runTransaction(async (trans) => { @@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => { const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) - if (netAmount === 0) { + if (floatingEqual(netAmount, 0)) { return { status: 'success' } } const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index 924f5c22..94f834b7 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -15,7 +15,10 @@ export const resetBettingStreaksForUsers = functions.pubsub }) const resetBettingStreaksInternal = async () => { - const usersSnap = await firestore.collection('users').get() + const usersSnap = await firestore + .collection('users') + .where('currentBettingStreak', '>', 0) + .get() const users = usersSnap.docs.map((doc) => doc.data() as User) @@ -28,7 +31,7 @@ const resetBettingStreakForUser = async (user: User) => { const betStreakResetTime = Date.now() - DAY_MS // if they made a bet within the last day, don't reset their streak if ( - (user.lastBetTime ?? 0 > betStreakResetTime) || + (user?.lastBetTime ?? 0) > betStreakResetTime || !user.currentBettingStreak || user.currentBettingStreak === 0 ) diff --git a/functions/src/scripts/backfill-unique-bettors.ts b/functions/src/scripts/backfill-unique-bettors.ts new file mode 100644 index 00000000..35faa54a --- /dev/null +++ b/functions/src/scripts/backfill-unique-bettors.ts @@ -0,0 +1,39 @@ +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { getValues, log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' +import { groupBy, mapValues, sortBy, uniq } from 'lodash' + +initAdmin() +const firestore = admin.firestore() + +const getBettorsByContractId = async () => { + const bets = await getValues(firestore.collectionGroup('bets')) + log(`Loaded ${bets.length} bets.`) + const betsByContractId = groupBy(bets, 'contractId') + return mapValues(betsByContractId, (bets) => + uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId)) + ) +} + +const updateUniqueBettors = async () => { + const bettorsByContractId = await getBettorsByContractId() + + const updates = Object.entries(bettorsByContractId).map( + ([contractId, userIds]) => { + const update = { + uniqueBettorIds: userIds, + uniqueBettorCount: userIds.length, + } + const docRef = firestore.collection('contracts').doc(contractId) + return { doc: docRef, fields: update } + } + ) + log(`Updating ${updates.length} contracts.`) + await writeAsync(firestore, updates) + log(`Updated all contracts.`) +} + +if (require.main === module) { + updateUniqueBettors() +} diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts deleted file mode 100644 index 3436bcbc..00000000 --- a/functions/src/scripts/convert-categories.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -import { getValues, isProd } from '../utils' -import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' -import { Group, GroupLink } from 'common/group' -import { uniq } from 'lodash' -import { Contract } from 'common/contract' -import { User } from 'common/user' -import { filterDefined } from 'common/util/array' -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from 'common/antes' - -initAdmin() - -const adminFirestore = admin.firestore() - -const convertCategoriesToGroupsInternal = async (categories: string[]) => { - for (const category of categories) { - const markets = await getValues( - adminFirestore - .collection('contracts') - .where('lowercaseTags', 'array-contains', category.toLowerCase()) - ) - const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX - const oldGroup = await getValues( - adminFirestore.collection('groups').where('slug', '==', slug) - ) - if (oldGroup.length > 0) { - console.log(`Found old group for ${category}`) - await adminFirestore.collection('groups').doc(oldGroup[0].id).delete() - } - - const allUsers = await getValues(adminFirestore.collection('users')) - const groupUsers = filterDefined( - allUsers.map((user: User) => { - if (!user.followedCategories || user.followedCategories.length === 0) - return user.id - if (!user.followedCategories.includes(category.toLowerCase())) - return null - return user.id - }) - ) - - const manifoldAccount = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - const newGroupRef = await adminFirestore.collection('groups').doc() - const newGroup: Group = { - id: newGroupRef.id, - name: category, - slug, - creatorId: manifoldAccount, - createdTime: Date.now(), - anyoneCanJoin: true, - memberIds: [manifoldAccount], - about: 'Default group for all things related to ' + category, - mostRecentActivityTime: Date.now(), - contractIds: markets.map((market) => market.id), - chatDisabled: true, - } - - await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup) - // Update group with new memberIds to avoid notifying everyone - await adminFirestore - .collection('groups') - .doc(newGroupRef.id) - .update({ - memberIds: uniq(groupUsers), - }) - - for (const market of markets) { - if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) - continue // already in that group - - const newGroupLinks = [ - ...(market.groupLinks ?? []), - { - groupId: newGroup.id, - createdTime: Date.now(), - slug: newGroup.slug, - name: newGroup.name, - } as GroupLink, - ] - await adminFirestore - .collection('contracts') - .doc(market.id) - .update({ - groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), - groupLinks: newGroupLinks, - }) - } - } -} - -async function convertCategoriesToGroups() { - // const defaultCategories = Object.values(DEFAULT_CATEGORIES) - const moreCategories = ['world', 'culture'] - await convertCategoriesToGroupsInternal(moreCategories) -} - -if (require.main === module) { - convertCategoriesToGroups() - .then(() => process.exit()) - .catch(console.log) -} diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 48f14e27..3240357e 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -4,21 +4,23 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' import { isProd, log } from '../utils' import { getSlug } from '../create-group' -import { Group } from '../../../common/group' +import { Group, GroupLink } from '../../../common/group' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' -const getTaggedContractIds = async (tag: string) => { +const getTaggedContracts = async (tag: string) => { const firestore = admin.firestore() const results = await firestore .collection('contracts') .where('lowercaseTags', 'array-contains', tag.toLowerCase()) .get() - return results.docs.map((d) => d.id) + return results.docs.map((d) => d.data() as Contract) } const createGroup = async ( name: string, about: string, - contractIds: string[] + contracts: Contract[] ) => { const firestore = admin.firestore() const creatorId = isProd() @@ -36,21 +38,60 @@ const createGroup = async ( about, createdTime: now, mostRecentActivityTime: now, - contractIds: contractIds, anyoneCanJoin: true, - memberIds: [], + totalContracts: contracts.length, + totalMembers: 1, } - return await groupRef.create(group) + await groupRef.create(group) + // create a GroupMemberDoc for the creator + const memberDoc = groupRef.collection('groupMembers').doc(creatorId) + await memberDoc.create({ + userId: creatorId, + createdTime: now, + }) + + // create GroupContractDocs for each contractId + await Promise.all( + contracts + .map((c) => c.id) + .map((contractId) => + groupRef.collection('groupContracts').doc(contractId).create({ + contractId, + createdTime: now, + }) + ) + ) + for (const market of contracts) { + if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: group.id, + createdTime: Date.now(), + slug: group.slug, + name: group.name, + } as GroupLink, + ] + await firestore + .collection('contracts') + .doc(market.id) + .update({ + groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + return { status: 'success', group: group } } const convertTagToGroup = async (tag: string, groupName: string) => { log(`Looking up contract IDs with tag ${tag}...`) - const contractIds = await getTaggedContractIds(tag) - log(`${contractIds.length} contracts found.`) - if (contractIds.length > 0) { + const contracts = await getTaggedContracts(tag) + log(`${contracts.length} contracts found.`) + if (contracts.length > 0) { log(`Creating group ${groupName}...`) const about = `Contracts that used to be tagged ${tag}.` - const result = await createGroup(groupName, about, contractIds) + const result = await createGroup(groupName, about, contracts) log(`Done. Group: `, result) } } diff --git a/functions/src/scripts/denormalize-comment-bet-data.ts b/functions/src/scripts/denormalize-comment-bet-data.ts new file mode 100644 index 00000000..929626c3 --- /dev/null +++ b/functions/src/scripts/denormalize-comment-bet-data.ts @@ -0,0 +1,69 @@ +// Filling in the bet-based fields on comments. + +import * as admin from 'firebase-admin' +import { zip } from 'lodash' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { log } from '../utils' +import { Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getBetComments(transaction: Transaction) { + const allComments = await transaction.get( + firestore.collectionGroup('comments') + ) + const betComments = allComments.docs.filter((d) => d.get('betId')) + log(`Found ${betComments.length} comments associated with bets.`) + return betComments +} + +async function denormalize() { + let hasMore = true + while (hasMore) { + hasMore = await admin.firestore().runTransaction(async (trans) => { + const betComments = await getBetComments(trans) + const bets = await Promise.all( + betComments.map((doc) => + trans.get( + firestore + .collection('contracts') + .doc(doc.get('contractId')) + .collection('bets') + .doc(doc.get('betId')) + ) + ) + ) + log(`Found ${bets.length} bets associated with comments.`) + const mapping = zip(bets, betComments) + .map(([bet, comment]): DocumentCorrespondence => { + return [bet!, [comment!]] // eslint-disable-line + }) + .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs + + const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') + const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') + log(`Found ${amountDiffs.length} comments with mismatched amounts.`) + log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) + const diffs = amountDiffs.concat(outcomeDiffs) + diffs.slice(0, 500).forEach((d) => { + log(describeDiff(d)) + applyDiff(trans, d) + }) + if (diffs.length > 500) { + console.log(`Applying first 500 because of Firestore limit...`) + } + return diffs.length > 500 + }) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts new file mode 100644 index 00000000..fc402292 --- /dev/null +++ b/functions/src/scripts/update-groups.ts @@ -0,0 +1,122 @@ +import * as admin from 'firebase-admin' +import { Group } from 'common/group' +import { initAdmin } from 'functions/src/scripts/script-init' +import { log } from '../utils' + +const getGroups = async () => { + const firestore = admin.firestore() + const groups = await firestore.collection('groups').get() + return groups.docs.map((doc) => doc.data() as Group) +} + +// const createContractIdForGroup = async ( +// groupId: string, +// contractId: string +// ) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const contractDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .get() +// if (!contractDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .create({ +// contractId, +// createdTime: now, +// }) +// } + +// const createMemberForGroup = async (groupId: string, userId: string) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const memberDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .get() +// if (!memberDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .create({ +// userId, +// createdTime: now, +// }) +// } + +// async function convertGroupFieldsToGroupDocuments() { +// const groups = await getGroups() +// for (const group of groups) { +// log('updating group', group.slug) +// const groupRef = admin.firestore().collection('groups').doc(group.id) +// const totalMembers = (await groupRef.collection('groupMembers').get()).size +// const totalContracts = (await groupRef.collection('groupContracts').get()) +// .size +// if ( +// totalMembers === group.memberIds?.length && +// totalContracts === group.contractIds?.length +// ) { +// log('group already converted', group.slug) +// continue +// } +// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 +// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 +// for (const contractId of group.contractIds?.slice( +// contractStart, +// group.contractIds?.length +// ) ?? []) { +// await createContractIdForGroup(group.id, contractId) +// } +// for (const userId of group.memberIds?.slice( +// membersStart, +// group.memberIds?.length +// ) ?? []) { +// await createMemberForGroup(group.id, userId) +// } +// } +// } + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function updateTotalContractsAndMembers() { + const groups = await getGroups() + for (const group of groups) { + log('updating group total contracts and members', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + const totalMembers = (await groupRef.collection('groupMembers').get()).size + const totalContracts = (await groupRef.collection('groupContracts').get()) + .size + await groupRef.update({ + totalMembers, + totalContracts, + }) + } +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function removeUnusedMemberAndContractFields() { + const groups = await getGroups() + for (const group of groups) { + log('removing member and contract ids', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + await groupRef.update({ + memberIds: admin.firestore.FieldValue.delete(), + contractIds: admin.firestore.FieldValue.delete(), + }) + } +} + +if (require.main === module) { + initAdmin() + // convertGroupFieldsToGroupDocuments() + // updateTotalContractsAndMembers() + removeUnusedMemberAndContractFields() +} diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 8d848f7f..a5291f19 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -26,7 +26,7 @@ import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' -import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -65,8 +65,8 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) -addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) +addEndpointRoute('/createpost', createpost) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index a2e72053..430f3d33 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,43 +1,29 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash' +import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' -import { Contract } from '../../common/contract' +import { Contract, CPMM } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' -import { calculatePayout } from '../../common/calculate' import { DAY_MS } from '../../common/util/time' -import { last } from 'lodash' import { getLoanUpdates } from '../../common/loans' +import { + calculateCreatorVolume, + calculateNewPortfolioMetrics, + calculateNewProfit, + calculateProbChanges, + computeVolume, +} from '../../common/calculate-metrics' +import { getProbability } from '../../common/calculate' const firestore = admin.firestore() -const computeInvestmentValue = ( - bets: Bet[], - contractsDict: { [k: string]: Contract } -) => { - return sumBy(bets, (bet) => { - const contract = contractsDict[bet.contractId] - if (!contract || contract.isResolved) return 0 - if (bet.sale || bet.isSold) return 0 +export const updateMetrics = functions + .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 15 minutes') + .onRun(updateMetricsCore) - const payout = calculatePayout(contract, bet, 'MKT') - const value = payout - (bet.loanAmount ?? 0) - if (isNaN(value)) return 0 - return value - }) -} - -const computeTotalPool = (userContracts: Contract[], startTime = 0) => { - const periodFilteredContracts = userContracts.filter( - (contract) => contract.createdTime >= startTime - ) - return sum( - periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) - ) -} - -export const updateMetricsCore = async () => { +export async function updateMetricsCore() { const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ getValues(firestore.collection('users')), getValues(firestore.collection('contracts')), @@ -55,16 +41,36 @@ export const updateMetricsCore = async () => { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) - const contractUpdates = contracts.map((contract) => { - const contractBets = betsByContract[contract.id] ?? [] - return { - doc: firestore.collection('contracts').doc(contract.id), - fields: { - volume24Hours: computeVolume(contractBets, now - DAY_MS), - volume7Days: computeVolume(contractBets, now - DAY_MS * 7), - }, - } - }) + const contractUpdates = contracts + .filter((contract) => contract.id) + .map((contract) => { + const contractBets = betsByContract[contract.id] ?? [] + const descendingBets = sortBy( + contractBets, + (bet) => bet.createdTime + ).reverse() + + let cpmmFields: Partial = {} + if (contract.mechanism === 'cpmm-1') { + const prob = descendingBets[0] + ? descendingBets[0].probAfter + : getProbability(contract) + + cpmmFields = { + prob, + probChanges: calculateProbChanges(descendingBets), + } + } + + return { + doc: firestore.collection('contracts').doc(contract.id), + fields: { + volume24Hours: computeVolume(contractBets, now - DAY_MS), + volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + ...cpmmFields, + }, + } + }) await writeAsync(firestore, contractUpdates) log(`Updated metrics for ${contracts.length} contracts.`) @@ -86,23 +92,20 @@ export const updateMetricsCore = async () => { currentBets ) const lastPortfolio = last(portfolioHistory) - const didProfitChange = + const didPortfolioChange = lastPortfolio === undefined || lastPortfolio.balance !== newPortfolio.balance || lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || lastPortfolio.investmentValue !== newPortfolio.investmentValue - const newProfit = calculateNewProfit( - portfolioHistory, - newPortfolio, - didProfitChange - ) + const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + return { user, newCreatorVolume, newPortfolio, newProfit, - didProfitChange, + didPortfolioChange, } }) @@ -118,16 +121,20 @@ export const updateMetricsCore = async () => { const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const userUpdates = userMetrics.map( - ({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => { + ({ + user, + newCreatorVolume, + newPortfolio, + newProfit, + didPortfolioChange, + }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { fieldUpdates: { doc: firestore.collection('users').doc(user.id), fields: { creatorVolumeCached: newCreatorVolume, - ...(didProfitChange && { - profitCached: newProfit, - }), + profitCached: newProfit, nextLoanCached, }, }, @@ -138,11 +145,7 @@ export const updateMetricsCore = async () => { .doc(user.id) .collection('portfolioHistory') .doc(), - fields: { - ...(didProfitChange && { - ...newPortfolio, - }), - }, + fields: didPortfolioChange ? newPortfolio : {}, }, } } @@ -160,108 +163,3 @@ export const updateMetricsCore = async () => { ) log(`Updated metrics for ${users.length} users.`) } - -const computeVolume = (contractBets: Bet[], since: number) => { - return sumBy(contractBets, (b) => - b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 - ) -} - -const calculateProfitForPeriod = ( - startTime: number, - portfolioHistory: PortfolioMetrics[], - currentProfit: number -) => { - const startingPortfolio = [...portfolioHistory] - .reverse() // so we search in descending order (most recent first), for efficiency - .find((p) => p.timestamp < startTime) - - if (startingPortfolio === undefined) { - return 0 - } - - const startingProfit = calculateTotalProfit(startingPortfolio) - - return currentProfit - startingProfit -} - -const calculateTotalProfit = (portfolio: PortfolioMetrics) => { - return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits -} - -const calculateCreatorVolume = (userContracts: Contract[]) => { - const allTimeCreatorVolume = computeTotalPool(userContracts, 0) - const monthlyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 30 * DAY_MS - ) - const weeklyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 7 * DAY_MS - ) - - const dailyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 1 * DAY_MS - ) - - return { - daily: dailyCreatorVolume, - weekly: weeklyCreatorVolume, - monthly: monthlyCreatorVolume, - allTime: allTimeCreatorVolume, - } -} - -const calculateNewPortfolioMetrics = ( - user: User, - contractsById: { [k: string]: Contract }, - currentBets: Bet[] -) => { - const investmentValue = computeInvestmentValue(currentBets, contractsById) - const newPortfolio = { - investmentValue: investmentValue, - balance: user.balance, - totalDeposits: user.totalDeposits, - timestamp: Date.now(), - userId: user.id, - } - return newPortfolio -} - -const calculateNewProfit = ( - portfolioHistory: PortfolioMetrics[], - newPortfolio: PortfolioMetrics, - didProfitChange: boolean -) => { - if (!didProfitChange) { - return {} // early return for performance - } - - const allTimeProfit = calculateTotalProfit(newPortfolio) - const newProfit = { - daily: calculateProfitForPeriod( - Date.now() - 1 * DAY_MS, - portfolioHistory, - allTimeProfit - ), - weekly: calculateProfitForPeriod( - Date.now() - 7 * DAY_MS, - portfolioHistory, - allTimeProfit - ), - monthly: calculateProfitForPeriod( - Date.now() - 30 * DAY_MS, - portfolioHistory, - allTimeProfit - ), - allTime: allTimeProfit, - } - - return newProfit -} - -export const updateMetrics = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) - .pubsub.schedule('every 15 minutes') - .onRun(updateMetricsCore) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 2d620728..a0878e4f 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,6 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' +import { Post } from 'common/post' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => { return getDoc('groups', groupId) } +export const getPost = (postId: string) => { + return getDoc('posts', postId) +} + export const getUser = (userId: string) => { return getDoc('users', userId) } diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index bf839d00..50f7195a 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -2,10 +2,18 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { getAllPrivateUsers, getUser, getValues, log } from './utils' +import { + getAllPrivateUsers, + getPrivateUser, + getUser, + getValues, + isProd, + log, +} from './utils' import { sendInterestingMarketsEmail } from './emails' import { createRNG, shuffle } from '../../common/util/random' import { DAY_MS } from '../../common/util/time' +import { filterDefined } from '../../common/util/array' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -34,7 +42,9 @@ export async function getTrendingContracts() { async function sendTrendingMarketsEmailsToAllUsers() { const numContractsToSend = 6 - const privateUsers = await getAllPrivateUsers() + const privateUsers = isProd() + ? await getAllPrivateUsers() + : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return ( diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts index 6dc43ac1..647d69b6 100644 --- a/og-image/api/_lib/challenge-template.ts +++ b/og-image/api/_lib/challenge-template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getChallengeHtml(parsedReq: ParsedRequest) { const { @@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) {
diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index 6d5c9b3d..131a3cc4 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) { creatorName, creatorUsername, creatorAvatarUrl, + resolution, // Challenge attributes: challengerAmount, @@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', + resolution: getString(resolution), probability: getString(probability), numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', diff --git a/og-image/api/_lib/template-css.ts b/og-image/api/_lib/template-css.ts new file mode 100644 index 00000000..f4ca6660 --- /dev/null +++ b/og-image/api/_lib/template-css.ts @@ -0,0 +1,81 @@ +import { sanitizeHtml } from './sanitizer' + +export function getTemplateCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index f59740c5..f8e235b7 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getHtml(parsedReq: ParsedRequest) { const { @@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) { creatorUsername, creatorAvatarUrl, numericValue, + resolution, } = parsedReq const MAX_QUESTION_CHARS = 100 const truncatedQuestion = @@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) { ? question.slice(0, MAX_QUESTION_CHARS) + '...' : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + + let resolutionColor = 'text-primary' + let resolutionString = 'YES' + switch (resolution) { + case 'YES': + break + case 'NO': + resolutionColor = 'text-red-500' + resolutionString = 'NO' + break + case 'CANCEL': + resolutionColor = 'text-yellow-500' + resolutionString = 'N/A' + break + case 'MKT': + resolutionColor = 'text-blue-500' + resolutionString = numericValue ? numericValue : probability + break + } + + const resolutionDiv = ` + +
+ ${resolutionString} +
+
${ + resolution === 'CANCEL' ? '' : 'resolved' + }
+
` + + const probabilityDiv = ` + +
${probability}
+
chance
+
` + + const numericValueDiv = ` + +
${numericValue}
+
expected
+
+ ` + return ` @@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
@@ -148,21 +112,22 @@ export function getHtml(parsedReq: ParsedRequest) {
${truncatedQuestion}
-
-
${probability}
-
${probability !== '' ? 'chance' : ''}
- -
${ - numericValue !== '' && probability === '' ? numericValue : '' - }
-
${numericValue !== '' ? 'expected' : ''}
-
+
+ ${ + resolution + ? resolutionDiv + : numericValue + ? numericValueDiv + : probability + ? probabilityDiv + : '' + }
-
+
${metadata}
diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index ef0a8135..ac1e7699 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -19,6 +19,7 @@ export interface ParsedRequest { creatorName: string creatorUsername: string creatorAvatarUrl: string + resolution: string // Challenge attributes: challengerAmount: string challengerOutcome: string diff --git a/package.json b/package.json index 05924ef0..dd60d92b 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,22 @@ "web" ], "scripts": { - "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" + "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)", + "lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix" }, "dependencies": {}, "devDependencies": { - "@typescript-eslint/eslint-plugin": "5.25.0", - "@typescript-eslint/parser": "5.25.0", "@types/node": "16.11.11", + "@typescript-eslint/eslint-plugin": "5.36.0", + "@typescript-eslint/parser": "5.36.0", "concurrently": "6.5.1", - "eslint": "8.15.0", + "eslint": "8.23.0", "eslint-plugin-lodash": "^7.4.0", - "prettier": "2.5.0", - "typescript": "4.6.4", + "eslint-plugin-unused-imports": "^2.0.0", + "nodemon": "2.0.19", + "prettier": "2.7.1", "ts-node": "10.9.1", - "nodemon": "2.0.19" + "typescript": "4.8.2" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 0f103080..56f12813 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { parser: '@typescript-eslint/parser', - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', @@ -22,6 +22,7 @@ module.exports = { '@next/next/no-typos': 'off', 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], + 'unused-imports/no-unused-imports': 'error', }, env: { browser: true, diff --git a/web/components/advanced-panel.tsx b/web/components/advanced-panel.tsx deleted file mode 100644 index 51caba67..00000000 --- a/web/components/advanced-panel.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import clsx from 'clsx' -import { useState, ReactNode } from 'react' - -export function AdvancedPanel(props: { children: ReactNode }) { - const { children } = props - const [collapsed, setCollapsed] = useState(true) - - return ( -
-
setCollapsed((collapsed) => !collapsed)} - className="cursor-pointer" - > -
- Advanced -
-
-
- -
- {children} -
-
- ) -} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 971a5496..9eff26ef 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -84,6 +84,7 @@ export function BuyAmountInput(props: { setError: (error: string | undefined) => void minimumAmount?: number disabled?: boolean + showSliderOnMobile?: boolean className?: string inputClassName?: string // Needed to focus the amount input @@ -94,6 +95,7 @@ export function BuyAmountInput(props: { onChange, error, setError, + showSliderOnMobile: showSlider, disabled, className, inputClassName, @@ -121,15 +123,28 @@ export function BuyAmountInput(props: { } return ( - + <> + + {showSlider && ( + onAmountChange(parseInt(e.target.value))} + className="range range-lg z-40 mb-2 xl:hidden" + step="5" + /> + )} + ) } diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx index 2f987d58..56e71257 100644 --- a/web/components/analytics/charts.tsx +++ b/web/components/analytics/charts.tsx @@ -1,4 +1,5 @@ import { Point, ResponsiveLine } from '@nivo/line' +import clsx from 'clsx' import dayjs from 'dayjs' import { zip } from 'lodash' import { useWindowSize } from 'web/hooks/use-window-size' @@ -26,8 +27,10 @@ export function DailyCountChart(props: { return (
= 800) ? 400 : 250 }} + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} > = 800) ? 400 : 250 }} + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} >
- Bet on {isModal ? `"${answer.text}"` : 'this answer'} + Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
{!isModal && ( @@ -132,7 +132,11 @@ export function AnswerBetPanel(props: { )}
-
Amount
+ + Amount + Balance: {formatMoney(user?.balance ?? 0)} + + {(betAmount ?? 0) > 10 && @@ -201,10 +206,10 @@ export function AnswerBetPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit trade'} + {isSubmitting ? 'Submitting...' : 'Submit'} ) : ( - + )} ) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 27152db9..e4167d11 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }) { const { contract, bets, height } = props const { createdTime, resolutionTime, closeTime, answers } = contract + const now = Date.now() const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( bets, contract ) - const isClosed = !!closeTime && Date.now() > closeTime + const isClosed = !!closeTime && now > closeTime const latestTime = dayjs( resolutionTime && isClosed ? Math.min(resolutionTime, closeTime) : isClosed ? closeTime - : resolutionTime ?? Date.now() + : resolutionTime ?? now ) const { width } = useWindowSize() @@ -71,13 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const hoursAgo = latestTime.subtract(5, 'hours') - const startDate = dayjs(contract.createdTime).isBefore(hoursAgo) - ? new Date(contract.createdTime) - : hoursAgo.toDate() + const startDate = dayjs(contract.createdTime) + const endDate = startDate.add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours') + : latestTime + const includeMinute = endDate.diff(startDate, 'hours') < 2 - const multiYear = !dayjs(startDate).isSame(latestTime, 'year') - const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) + const multiYear = !startDate.isSame(latestTime, 'year') + const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) return (
- formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) + formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, - format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), + format: (time) => + formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), }} - colors={{ scheme: 'pastel1' }} + colors={[ + '#fca5a5', // red-300 + '#a5b4fc', // indigo-300 + '#86efac', // green-300 + '#fef08a', // yellow-200 + '#fdba74', // orange-300 + '#c084fc', // purple-400 + ]} pointSize={0} curve="stepAfter" enableSlices="x" @@ -149,19 +159,20 @@ function formatPercent(y: DatumValue) { } function formatTime( + now: number, time: number, includeYear: boolean, includeHour: boolean, includeMinute: boolean ) { const d = dayjs(time) - - if (d.add(1, 'minute').isAfter(Date.now())) return 'Now' + if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) + return 'Now' let format: string - if (d.isSame(Date.now(), 'day')) { + if (d.isSame(now, 'day')) { format = '[Today]' - } else if (d.add(1, 'day').isSame(Date.now(), 'day')) { + } else if (d.add(1, 'day').isSame(now, 'day')) { format = '[Yesterday]' } else { format = 'MMM D' diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 6e0bfef6..e53153b1 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item' import { CreateAnswerPanel } from './create-answer-panel' import { AnswerResolvePanel } from './answer-resolve-panel' import { Spacer } from '../layout/spacer' -import { ActivityItem } from '../feed/activity-items' import { User } from 'common/user' import { getOutcomeProbability } from 'common/calculate' import { Answer } from 'common/answer' @@ -21,9 +20,9 @@ import { Modal } from 'web/components/layout/modal' import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' +import { UserLink } from 'web/components/user-link' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract @@ -176,7 +175,6 @@ function getAnswerItems( type: 'answer' as const, contract, answer, - items: [] as ActivityItem[], user, } }) @@ -186,7 +184,6 @@ function getAnswerItems( function OpenAnswer(props: { contract: FreeResponseContract | MultipleChoiceContract answer: Answer - items: ActivityItem[] type: string }) { const { answer, contract } = props diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ce266778..7e20e92e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useState } from 'react' +import React, { useState } from 'react' import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' @@ -25,6 +25,7 @@ import { Bet } from 'common/bet' import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' +import { Button } from '../button' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -115,9 +116,11 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + if (user?.isBannedFromPosting) return <> + return ( - +
Add your answer