diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dcf81c44..e441edcf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,4 +52,4 @@ jobs: - name: Run Typescript checker on cloud functions if: ${{ success() || failure() }} working-directory: functions - run: tsc --pretty --project tsconfig.json --noEmit + run: tsc -b -v --pretty diff --git a/common/.gitignore b/common/.gitignore index e0ba0181..11320851 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -1,6 +1,5 @@ # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ @@ -10,4 +9,4 @@ node_modules/ package-lock.json ui-debug.log -firebase-debug.log \ No newline at end of file +firebase-debug.log diff --git a/common/charity.ts b/common/charity.ts index 249bcc51..0d8a0aa6 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -516,6 +516,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`, }, + { + name: 'The Center for Election Science', + website: 'https://electionscience.org/', + photo: 'https://i.imgur.com/WvdHHZa.png', + preview: + 'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.', + description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform. + +Our Mission — To empower people with voting methods that strengthen democracy. + +Our Vision — A world where democracies thrive because voters’ voices are heard. + +With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research. + +The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/notification.ts b/common/notification.ts index 919cf917..64a00a36 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -33,6 +33,7 @@ export type notification_source_types = | 'tip' | 'admin_message' | 'group' + | 'user' export type notification_source_update_types = | 'created' @@ -53,3 +54,5 @@ export type notification_reason_types = | 'on_new_follow' | 'you_follow_user' | 'added_you_to_group' + | 'you_referred_user' + | 'user_joined_to_bet_on_your_market' diff --git a/common/package.json b/common/package.json index 1bd67851..c8115d84 100644 --- a/common/package.json +++ b/common/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "private": true, "scripts": { - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx eslint . --max-warnings 0" }, "sideEffects": false, "dependencies": { diff --git a/common/payouts.ts b/common/payouts.ts index b02904ac..1469cf4e 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -53,12 +53,12 @@ export type PayoutInfo = { export const getPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: Contract, bets: Bet[], liquidities: LiquidityProvision[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { if ( @@ -76,9 +76,9 @@ export const getPayouts = ( } return getDpmPayouts( outcome, - resolutions, contract, bets, + resolutions, resolutionProbability ) } @@ -109,11 +109,11 @@ export const getFixedPayouts = ( export const getDpmPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: DPMContract, bets: Bet[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { const openBets = bets.filter((b) => !b.isSold && !b.sale) @@ -124,8 +124,8 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' - ? getPayoutsMultiOutcome(resolutions, contract, openBets) + return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': case undefined: diff --git a/common/scoring.ts b/common/scoring.ts index d4e40267..39a342fd 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) { ) const { payouts: resolvePayouts } = getPayouts( resolution as string, - {}, contract, openBets, [], + {}, resolutionProb ) diff --git a/common/tsconfig.json b/common/tsconfig.json index 158a5218..62a5c745 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, + "module": "commonjs", "moduleResolution": "node", "noImplicitReturns": true, "outDir": "lib", diff --git a/common/txn.ts b/common/txn.ts index 25d4a1c3..0e772e0d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink +type AnyTxnType = Donation | Tip | Manalink | Referral type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,7 +16,7 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET' // Any extra data data?: { [key: string]: any } @@ -46,6 +46,13 @@ type Manalink = { category: 'MANALINK' } +type Referral = { + fromType: 'BANK' + toType: 'USER' + category: 'REFERRAL' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink +export type ReferralTxn = Txn & Referral diff --git a/common/user.ts b/common/user.ts index 298fee56..0a8565dd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -33,11 +33,14 @@ export type User = { followerCountCached: number followedCategories?: string[] + + referredByUserId?: string + referredByContractId?: string } export const STARTING_BALANCE = 1000 export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person - +export const REFERRAL_AMOUNT = 500 export type PrivateUser = { id: string // same as User.id username: string // denormalized from User diff --git a/docs/docs/api.md b/docs/docs/api.md index ffdaa65f..a8ac18fe 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -456,7 +456,6 @@ Requires no authorization. } ``` - ### `POST /v0/bet` Places a new bet on behalf of the authorized user. @@ -514,6 +513,60 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/resolve` + +Resolves a market on behalf of the authorized user. + +Parameters: + +For binary markets: + +- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. +- `probabilityInt`: Optional. The probability to use for `MKT` resolution. + +For free response markets: + +- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. +- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. + +For numeric markets: + +- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. +- `value`: The value that the market may resolves to. + +Example request: + +``` +# Resolve a binary market +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES"}' + +# Resolve a binary market with a specified probability +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "MKT", \ + "probabilityInt": 75}' + +# Resolve a free response market with a single answer chosen +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": 2}' + +# Resolve a free response market with multiple answers chosen +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "MKT", \ + "resolutions": [ \ + {"answer": 0, "pct": 50}, \ + {"answer": 2, "pct": 50} \ + ]}' +``` + ## Changelog - 2022-06-08: Add paging to markets endpoint diff --git a/docs/docs/market-details.md b/docs/docs/market-details.md index f7eeb0f6..9836b850 100644 --- a/docs/docs/market-details.md +++ b/docs/docs/market-details.md @@ -19,7 +19,6 @@ for the pool to be sorted into. - Users can create a market on any question they want. - When a user creates a market, they must choose a close date, after which trading will halt. - They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market. - - The creation fee for the first market created each day is provided by Manifold. - The market creator will earn a commission on all bets placed in the market. - The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution. - Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares. diff --git a/firebase.json b/firebase.json index de1e19b7..25f9b61f 100644 --- a/firebase.json +++ b/firebase.json @@ -1,8 +1,8 @@ { "functions": { - "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", + "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions" + "source": "functions/dist" }, "firestore": { "rules": "firestore.rules", diff --git a/firestore.rules b/firestore.rules index 176cc71e..50df415a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -20,7 +20,12 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); + // only one referral allowed per user + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId']) + && !("referredByUserId" in resource.data); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/functions/.gitignore b/functions/.gitignore index 7aeaedd4..2aeae30c 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -2,9 +2,11 @@ .env* .runtimeconfig.json +# GCP deployment artifact +dist/ + # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ diff --git a/functions/package.json b/functions/package.json index 7b5c30b0..eb6c7151 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,8 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "tsc", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", + "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", @@ -16,9 +17,10 @@ "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" }, - "main": "lib/functions/src/index.js", + "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", "fetch": "1.1.0", diff --git a/functions/src/api.ts b/functions/src/api.ts index f7efab5a..290ea3d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -108,7 +108,12 @@ export const validate = (schema: T, val: unknown) => { } } -const DEFAULT_OPTS: HttpsOptions = { +interface EndpointOptions extends HttpsOptions { + methods?: string[] +} + +const DEFAULT_OPTS = { + methods: ['POST'], minInstances: 1, concurrency: 100, memory: '2GiB', @@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = { cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], } -export const newEndpoint = (methods: [string], fn: Handler) => - onRequest(DEFAULT_OPTS, async (req, res) => { +export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { + const opts = Object.assign(endpointOpts, DEFAULT_OPTS) + return onRequest(opts, async (req, res) => { log('Request processing started.') try { - if (!methods.includes(req.method)) { - const allowed = methods.join(', ') + if (!opts.methods.includes(req.method)) { + const allowed = opts.methods.join(', ') throw new APIError(405, `This endpoint supports only ${allowed}.`) } const authedUser = await lookupUser(await parseCredentials(req)) @@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) => } } }) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index ecdcdf2e..c592cd88 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -53,7 +53,7 @@ const numericSchema = z.object({ isLogScale: z.boolean().optional(), }) -export const createmarket = newEndpoint(['POST'], async (req, auth) => { +export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index e7ee0cf5..a9626916 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -20,7 +20,7 @@ const bodySchema = z.object({ about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), }) -export const creategroup = newEndpoint(['POST'], async (req, auth) => { +export const creategroup = newEndpoint({}, async (req, auth) => { const { name, about, memberIds, anyoneCanJoin } = validate( bodySchema, req.body diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index daf7e9d7..a32ed3bc 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -68,6 +68,7 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, + // TODO: move away from sourceContractSlug to sourceSlug sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, @@ -252,44 +253,62 @@ export const createNotification = async ( } } + const notifyUserReceivedReferralBonus = async ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string + ) => { + if (shouldGetNotification(relatedUserId, userToReasonTexts)) + userToReasonTexts[relatedUserId] = { + // If the referrer is the market creator, just tell them they joined to bet on their market + reason: + sourceContract?.creatorId === relatedUserId + ? 'user_joined_to_bet_on_your_market' + : 'you_referred_user', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. - if (sourceContract) { - if ( - sourceType === 'comment' || - sourceType === 'answer' || - (sourceType === 'contract' && - (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) - ) { - if (sourceType === 'comment') { - if (relatedUserId && relatedSourceType) - await notifyRepliedUsers( - userToReasonTexts, - relatedUserId, - relatedSourceType - ) - if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) - } - await notifyContractCreator(userToReasonTexts, sourceContract) - await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) - await notifyLiquidityProviders(userToReasonTexts, sourceContract) - await notifyBettorsOnContract(userToReasonTexts, sourceContract) - await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) - } else if (sourceType === 'contract' && sourceUpdateType === 'created') { - await notifyUsersFollowers(userToReasonTexts) - } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) - } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { - await notifyContractCreator(userToReasonTexts, sourceContract) - } - } else if (sourceType === 'follow' && relatedUserId) { + if (sourceType === 'follow' && relatedUserId) { await notifyFollowedUser(userToReasonTexts, relatedUserId) } else if (sourceType === 'group' && relatedUserId) { if (sourceUpdateType === 'created') await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + } else if (sourceType === 'user' && relatedUserId) { + await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } + + // The following functions need sourceContract to be defined. + if (!sourceContract) return userToReasonTexts + if ( + sourceType === 'comment' || + sourceType === 'answer' || + (sourceType === 'contract' && + (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) + ) { + if (sourceType === 'comment') { + if (relatedUserId && relatedSourceType) + await notifyRepliedUsers( + userToReasonTexts, + relatedUserId, + relatedSourceType + ) + if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) + } + await notifyContractCreator(userToReasonTexts, sourceContract) + await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) + await notifyLiquidityProviders(userToReasonTexts, sourceContract) + await notifyBettorsOnContract(userToReasonTexts, sourceContract) + await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) + } else if (sourceType === 'contract' && sourceUpdateType === 'created') { + await notifyUsersFollowers(userToReasonTexts) + } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { + await notifyContractCreator(userToReasonTexts, sourceContract, { + force: true, + }) + } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { + await notifyContractCreator(userToReasonTexts, sourceContract) } return userToReasonTexts } diff --git a/functions/src/health.ts b/functions/src/health.ts index 6f4d73dc..938261db 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,6 +1,6 @@ import { newEndpoint } from './api' -export const health = newEndpoint(['GET'], async (_req, auth) => { +export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => { return { message: 'Server is working.', uid: auth.uid, diff --git a/functions/src/index.ts b/functions/src/index.ts index dcd50e66..b643ff5e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,7 +6,6 @@ admin.initializeApp() // export * from './keep-awake' export * from './claim-manalink' export * from './transact' -export * from './resolve-market' export * from './stripe' export * from './create-user' export * from './create-answer' @@ -28,6 +27,7 @@ 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' // v2 export * from './health' @@ -37,3 +37,4 @@ export * from './sell-shares' export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' +export * from './resolve-market' diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts new file mode 100644 index 00000000..b6ba6e0b --- /dev/null +++ b/functions/src/on-update-user.ts @@ -0,0 +1,111 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { REFERRAL_AMOUNT, User } from '../../common/user' +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { createNotification } from './create-notification' +import { ReferralTxn } from '../../common/txn' +import { Contract } from '../../common/contract' +const firestore = admin.firestore() + +export const onUpdateUser = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + const prevUser = change.before.data() as User + const user = change.after.data() as User + const { eventId } = context + + if (prevUser.referredByUserId !== user.referredByUserId) { + await handleUserUpdatedReferral(user, eventId) + } + }) + +async function handleUserUpdatedReferral(user: User, eventId: string) { + // Only create a referral txn if the user has a referredByUserId + if (!user.referredByUserId) { + console.log(`Not set: referredByUserId ${user.referredByUserId}`) + return + } + const referredByUserId = user.referredByUserId + + await firestore.runTransaction(async (transaction) => { + // get user that referred this user + const referredByUserDoc = firestore.doc(`users/${referredByUserId}`) + const referredByUserSnap = await transaction.get(referredByUserDoc) + if (!referredByUserSnap.exists) { + console.log(`User ${referredByUserId} not found`) + return + } + const referredByUser = referredByUserSnap.data() as User + + let referredByContract: Contract | undefined = undefined + if (user.referredByContractId) { + const referredByContractDoc = firestore.doc( + `contracts/${user.referredByContractId}` + ) + referredByContract = await transaction + .get(referredByContractDoc) + .then((snap) => snap.data() as Contract) + } + console.log(`referredByContract: ${referredByContract}`) + + const txns = ( + await firestore + .collection('txns') + .where('toId', '==', referredByUserId) + .where('category', '==', 'REFERRAL') + .get() + ).docs.map((txn) => txn.ref) + if (txns.length > 0) { + const referralTxns = await transaction.getAll(...txns).catch((err) => { + console.error('error getting txns:', err) + throw err + }) + // If the referring user already has a referral txn due to referring this user, halt + if ( + referralTxns.map((txn) => txn.data()?.description).includes(user.id) + ) { + console.log('found referral txn with the same details, aborting') + return + } + } + console.log('creating referral txns') + const fromId = HOUSE_LIQUIDITY_PROVIDER_ID + + // if they're updating their referredId, create a txn for both + const txn: ReferralTxn = { + id: eventId, + createdTime: Date.now(), + fromId, + fromType: 'BANK', + toId: referredByUserId, + toType: 'USER', + amount: REFERRAL_AMOUNT, + token: 'M$', + category: 'REFERRAL', + description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`, + } + + const txnDoc = await firestore.collection(`txns/`).doc(txn.id) + await transaction.set(txnDoc, txn) + console.log('created referral with txn id:', txn.id) + // We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes. + transaction.update(referredByUserDoc, { + balance: referredByUser.balance + REFERRAL_AMOUNT, + totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, + }) + + await createNotification( + user.id, + 'user', + 'updated', + user, + eventId, + txn.amount.toString(), + referredByContract, + 'user', + referredByUser.id, + referredByContract?.slug, + referredByContract?.question + ) + }) +} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 6e066d7e..b6c7d267 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -33,7 +33,7 @@ const numericSchema = z.object({ value: z.number(), }) -export const placebet = newEndpoint(['POST'], async (req, auth) => { +export const placebet = newEndpoint({}, async (req, auth) => { log('Inside endpoint handler.') const { amount, contractId } = validate(bodySchema, req.body) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 87eaed58..89a7b75c 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,8 +1,12 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, resolution, RESOLUTIONS } from '../../common/contract' +import { + Contract, + FreeResponseContract, + RESOLUTIONS, +} from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -15,162 +19,156 @@ import { } from '../../common/payouts' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' +import { APIError, newEndpoint, validate } from './api' -export const resolveMarket = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - outcome: resolution - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), +}) - const { outcome, contractId, probabilityInt, resolutions, value } = data +const binarySchema = z.object({ + outcome: z.enum(RESOLUTIONS), + probabilityInt: z.number().gte(0).lte(100).optional(), +}) - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - const { creatorId, outcomeType, closeTime } = contract +const freeResponseSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + resolutions: z.array( + z.object({ + answer: z.number().int().nonnegative(), + pct: z.number().gte(0).lte(100), + }) + ), + }), + z.object({ + outcome: z.number().int().nonnegative(), + }), +]) - if (outcomeType === 'BINARY') { - if (!RESOLUTIONS.includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'FREE_RESPONSE') { - if ( - isNaN(+outcome) && - !(outcome === 'MKT' && resolutions) && - outcome !== 'CANCEL' - ) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'NUMERIC') { - if (isNaN(+outcome) && outcome !== 'CANCEL') - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'PSEUDO_NUMERIC') { - if (probabilityInt === undefined && outcome !== 'CANCEL') - return { status: 'error', message: 'Invalid outcome' } - } else { - return { status: 'error', message: 'Invalid contract outcomeType' } - } +const numericSchema = z.object({ + outcome: z.union([z.literal('CANCEL'), z.string()]), + value: z.number().optional(), +}) - if (value !== undefined && !isFinite(value)) - return { status: 'error', message: 'Invalid value' } +const pseudoNumericSchema = z.object({ + outcome: z.union([z.literal('CANCEL'), z.literal('MKT')]), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), +}) - if ( - (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && - probabilityInt !== undefined && - (probabilityInt < 0 || - probabilityInt > 100 || - !isFinite(probabilityInt)) - ) - return { status: 'error', message: 'Invalid probability' } +const opts = { secrets: ['MAILGUN_KEY'] } +export const resolvemarket = newEndpoint(opts, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) + const userId = auth.uid - if (creatorId !== userId) - return { status: 'error', message: 'User not creator of contract' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + throw new APIError(404, 'No contract exists with the provided ID') + const contract = contractSnap.data() as Contract + const { creatorId, closeTime } = contract - if (contract.resolution) - return { status: 'error', message: 'Contract already resolved' } - - const creator = await getUser(creatorId) - if (!creator) return { status: 'error', message: 'Creator not found' } - - const resolutionProbability = - probabilityInt !== undefined ? probabilityInt / 100 : undefined - - const resolutionTime = Date.now() - const newCloseTime = closeTime - ? Math.min(closeTime, resolutionTime) - : closeTime - - const betsSnap = await firestore - .collection(`contracts/${contractId}/bets`) - .get() - - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - - const liquiditiesSnap = await firestore - .collection(`contracts/${contractId}/liquidity`) - .get() - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const { payouts, creatorPayout, liquidityPayouts, collectedFees } = - getPayouts( - outcome, - resolutions ?? {}, - contract, - bets, - liquidities, - resolutionProbability - ) - - const resolutionInfo = - outcome !== 'CANCEL' - ? { resolutionValue: value, resolutionProbability, resolutions } - : {} - - await contractDoc.update( - removeUndefinedProps({ - isResolved: true, - resolution: outcome, - resolutionTime, - closeTime: newCloseTime, - collectedFees, - ...resolutionInfo, - }) - ) - - console.log('contract ', contractId, 'resolved to:', outcome) - - const openBets = bets.filter((b) => !b.isSold && !b.sale) - const loanPayouts = getLoanPayouts(openBets) - - if (!isProd()) - console.log( - 'payouts:', - payouts, - 'creator payout:', - creatorPayout, - 'liquidity payout:' - ) - - if (creatorPayout) - await processPayouts( - [{ userId: creatorId, payout: creatorPayout }], - true - ) - - await processPayouts(liquidityPayouts, true) - - const result = await processPayouts([...payouts, ...loanPayouts]) - - const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - - await sendResolutionEmails( - openBets, - userPayoutsWithoutLoans, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - - return result - } + const { value, resolutions, probabilityInt, outcome } = getResolutionParams( + contract, + req.body ) + if (creatorId !== userId) + throw new APIError(403, 'User is not creator of contract') + + if (contract.resolution) throw new APIError(400, 'Contract already resolved') + + const creator = await getUser(creatorId) + if (!creator) throw new APIError(500, 'Creator not found') + + const resolutionProbability = + probabilityInt !== undefined ? probabilityInt / 100 : undefined + + const resolutionTime = Date.now() + const newCloseTime = closeTime + ? Math.min(closeTime, resolutionTime) + : closeTime + + const betsSnap = await firestore + .collection(`contracts/${contractId}/bets`) + .get() + + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + + const liquiditiesSnap = await firestore + .collection(`contracts/${contractId}/liquidity`) + .get() + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const { payouts, creatorPayout, liquidityPayouts, collectedFees } = + getPayouts( + outcome, + contract, + bets, + liquidities, + resolutions, + resolutionProbability + ) + + const updatedContract = { + ...contract, + ...removeUndefinedProps({ + isResolved: true, + resolution: outcome, + resolutionValue: value, + resolutionTime, + closeTime: newCloseTime, + resolutionProbability, + resolutions, + collectedFees, + }), + } + + await contractDoc.update(updatedContract) + + console.log('contract ', contractId, 'resolved to:', outcome) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const loanPayouts = getLoanPayouts(openBets) + + if (!isProd()) + console.log( + 'payouts:', + payouts, + 'creator payout:', + creatorPayout, + 'liquidity payout:' + ) + + if (creatorPayout) + await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) + + await processPayouts(liquidityPayouts, true) + + await processPayouts([...payouts, ...loanPayouts]) + + const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) + + await sendResolutionEmails( + openBets, + userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions + ) + + return updatedContract +}) + const processPayouts = async (payouts: Payout[], isDeposit = false) => { const userPayouts = groupPayoutsByUser(payouts) @@ -227,4 +225,72 @@ const sendResolutionEmails = async ( ) } +function getResolutionParams(contract: Contract, body: string) { + const { outcomeType } = contract + + if (outcomeType === 'NUMERIC') { + return { + ...validate(numericSchema, body), + resolutions: undefined, + probabilityInt: undefined, + } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } + } else if (outcomeType === 'FREE_RESPONSE') { + const freeResponseParams = validate(freeResponseSchema, body) + const { outcome } = freeResponseParams + switch (outcome) { + case 'CANCEL': + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + case 'MKT': { + const { resolutions } = freeResponseParams + resolutions.forEach(({ answer }) => validateAnswer(contract, answer)) + const pctSum = sumBy(resolutions, ({ pct }) => pct) + if (Math.abs(pctSum - 100) > 0.1) { + throw new APIError(400, 'Resolution percentages must sum to 100') + } + return { + outcome: outcome.toString(), + resolutions: Object.fromEntries( + resolutions.map((r) => [r.answer, r.pct]) + ), + value: undefined, + probabilityInt: undefined, + } + } + default: { + validateAnswer(contract, outcome) + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + } + } + } else if (outcomeType === 'BINARY') { + return { + ...validate(binarySchema, body), + value: undefined, + resolutions: undefined, + } + } + throw new APIError(500, `Invalid outcome type: ${outcomeType}`) +} + +function validateAnswer(contract: FreeResponseContract, answer: number) { + const validIds = contract.answers.map((a) => a.id) + if (!validIds.includes(answer.toString())) { + throw new APIError(400, `${answer} is not a valid answer ID`) + } +} + const firestore = admin.firestore() diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts index 1686ebd9..a121889f 100644 --- a/functions/src/scripts/pay-out-contract-again.ts +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) { const { payouts } = getPayouts( resolution, - resolutions, contract, openBets, [], + resolutions, resolutionProbability ) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 419206c0..b3362159 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -13,7 +13,7 @@ const bodySchema = z.object({ betId: z.string(), }) -export const sellbet = newEndpoint(['POST'], async (req, auth) => { +export const sellbet = newEndpoint({}, async (req, auth) => { const { contractId, betId } = validate(bodySchema, req.body) // run as transaction to prevent race conditions diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index dd4e2ec5..26374a16 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -16,7 +16,7 @@ const bodySchema = z.object({ outcome: z.enum(['YES', 'NO']), }) -export const sellshares = newEndpoint(['POST'], async (req, auth) => { +export const sellshares = newEndpoint({}, async (req, auth) => { const { contractId, shares, outcome } = validate(bodySchema, req.body) // Run as transaction to prevent race conditions. diff --git a/functions/tsconfig.json b/functions/tsconfig.json index e183bb44..9496b9cb 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, "module": "commonjs", "noImplicitReturns": true, "outDir": "lib", @@ -8,6 +9,11 @@ "strict": true, "target": "es2017" }, + "references": [ + { + "path": "../common" + } + ], "compileOnSave": true, - "include": ["src", "../common/**/*.ts"] + "include": ["src"] } diff --git a/package.json b/package.json index a5c1e29e..e4aee3fd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "web" ], "scripts": { - "verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)" + "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" }, "dependencies": {}, "devDependencies": { diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 81b94550..6b8e2885 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' -import { sum, mapValues } from 'lodash' +import { sum } from 'lodash' import { useState } from 'react' import { Contract, FreeResponse } from 'common/contract' import { Col } from '../layout/col' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { Row } from '../layout/row' import { ChooseCancelSelector } from '../yes-no-selector' import { ResolveConfirmationButton } from '../confirmation-button' @@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: { setIsSubmitting(true) const totalProb = sum(Object.values(chosenAnswers)) - const normalizedProbs = mapValues( - chosenAnswers, - (prob) => (100 * prob) / totalProb - ) + const resolutions = Object.entries(chosenAnswers).map(([i, p]) => { + return { answer: parseInt(i), pct: (100 * p) / totalProb } + }) const resolutionProps = removeUndefinedProps({ outcome: resolveOption === 'CHOOSE' - ? answers[0] + ? parseInt(answers[0]) : resolveOption === 'CHOOSE_MULTIPLE' ? 'MKT' : 'CANCEL', resolutions: - resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined, + resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined, contractId: contract.id, }) - const result = await resolveMarket(resolutionProps).then((r) => r.data) - - console.log('resolved', resolutionProps, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket(resolutionProps) + console.log('resolved', resolutionProps, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setResolveOption(undefined) setIsSubmitting(false) } diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 6eeadf97..ed9012c9 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { setText('') setBetAmount(10) setAmountError(undefined) + setPossibleDuplicateAnswer(undefined) } else setAmountError(result.message) } } diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index fac02d74..9a4da597 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -122,7 +122,7 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` - if (IS_PRIVATE_MANIFOLD) { + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( @@ -192,6 +195,11 @@ export function ContractDetails(props: {
{volumeLabel}
+ {!disabled && } diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 7027d06a..12fd8dd9 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -13,7 +13,6 @@ import { getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' -import { CopyLinkButton } from '../copy-link-button' import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Row } from '../layout/row' @@ -23,6 +22,9 @@ import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' import { TagsInput } from 'web/components/tags-input' +export const contractDetailsButtonClassName = + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' + export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props @@ -48,13 +50,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { return ( <> @@ -66,10 +66,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
Share
-
updateOpen(!open)} > diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 5dedbc8f..114a9003 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -91,6 +91,9 @@ export function GroupChat(props: { setReplyToUsername('') inputRef?.focus() } + function focusInput() { + inputRef?.focus() + } return ( @@ -117,7 +120,13 @@ export function GroupChat(props: { ))} {messages.length === 0 && (
- No messages yet. 🦗... Why not say something? + No messages yet. Why not{' '} +
)} diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index 6bc943dc..ea1597f2 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -22,7 +22,7 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const [query, setQuery] = useState('') - const memberGroups = useMemberGroups(creator) + const memberGroups = useMemberGroups(creator?.id) const filteredGroups = memberGroups ? query === '' ? memberGroups diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx new file mode 100644 index 00000000..e6ee217d --- /dev/null +++ b/web/components/groups/groups-button.tsx @@ -0,0 +1,144 @@ +import clsx from 'clsx' +import { User } from 'common/user' +import { useState } from 'react' +import { useUser } from 'web/hooks/use-user' +import { withTracking } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import { useMemberGroups } from 'web/hooks/use-group' +import { TextButton } from 'web/components/text-button' +import { Group } from 'common/group' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' +import { firebaseLogin } from 'web/lib/firebase/users' +import { GroupLink } from 'web/pages/groups' + +export function GroupsButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + const groups = useMemberGroups(user.id) + + return ( + <> + setIsOpen(true)}> + {groups?.length ?? ''} Groups + + + + + ) +} + +function GroupsDialog(props: { + user: User + groups: Group[] + isOpen: boolean + setIsOpen: (isOpen: boolean) => void +}) { + const { user, groups, isOpen, setIsOpen } = props + + return ( + + +
{user.name}
+
@{user.username}
+ + +
+ ) +} + +function GroupsList(props: { groups: Group[] }) { + const { groups } = props + return ( + + {groups.length === 0 && ( +
No groups yet...
+ )} + {groups + .sort((group1, group2) => group2.createdTime - group1.createdTime) + .map((group) => ( + + ))} + + ) +} + +function GroupItem(props: { group: Group; className?: string }) { + const { group, className } = props + return ( + + + + + + + ) +} + +export function JoinOrLeaveGroupButton(props: { + group: Group + small?: boolean + className?: string +}) { + const { group, small, className } = props + const currentUser = useUser() + const isFollowing = currentUser + ? group.memberIds.includes(currentUser.id) + : false + const onJoinGroup = () => { + if (!currentUser) return + joinGroup(group, currentUser.id) + } + const onLeaveGroup = () => { + if (!currentUser) return + leaveGroup(group, currentUser.id) + } + + const smallStyle = + 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' + + if (!currentUser || isFollowing === undefined) { + if (!group.anyoneCanJoin) + return
Closed
+ return ( + + ) + } + + if (isFollowing) { + return ( + + ) + } + + if (!group.anyoneCanJoin) + return
Closed
+ return ( + + ) +} diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 23c9ab38..5a997b46 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -3,7 +3,6 @@ import Link from 'next/link' import { HomeIcon, MenuAlt3Icon, - PresentationChartLineIcon, SearchIcon, XIcon, } from '@heroicons/react/outline' @@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' -function getNavigation(username: string) { +function getNavigation() { return [ { name: 'Home', href: '/home', icon: HomeIcon }, - { - name: 'Portfolio', - href: `/${username}?tab=bets`, - icon: PresentationChartLineIcon, - }, { name: 'Notifications', href: `/notifications`, @@ -55,38 +49,39 @@ export function BottomNavBar() { } const navigationOptions = - user === null - ? signedOutNavigation - : getNavigation(user?.username || 'error') + user === null ? signedOutNavigation : getNavigation() return (
@@ -66,14 +77,12 @@ export default function Create() { } // Allow user to create a new contract -export function NewContract(props: { question: string; groupId?: string }) { - const { question, groupId } = props - const creator = useUser() - - useEffect(() => { - if (creator === null) router.push('/') - }, [creator]) - +export function NewContract(props: { + creator: User + question: string + groupId?: string +}) { + const { creator, question, groupId } = props const [outcomeType, setOutcomeType] = useState('BINARY') const [initialProb] = useState(50) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index a3b99128..3a3db14d 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -13,7 +13,12 @@ import { } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' -import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' +import { + firebaseLogin, + getUser, + User, + writeReferralInfo, +} from 'web/lib/firebase/users' import { Spacer } from 'web/components/layout/spacer' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' @@ -40,6 +45,9 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' import ShortToggle from 'web/components/widgets/short-toggle' +import { ShareIconButton } from 'web/components/share-icon-button' +import { REFERRAL_AMOUNT } from 'common/user' +import { SiteLink } from 'web/components/site-link' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -150,6 +158,14 @@ export default function GroupPage(props: { }, [group]) const user = useUser() + useEffect(() => { + const { referrer } = router.query as { + referrer?: string + } + if (!user && router.isReady) + writeReferralInfo(creator.username, undefined, referrer, group?.slug) + }, [user, creator, group, router]) + if (group === null || !groupSubpages.includes(page) || slugs[2]) { return } @@ -257,7 +273,13 @@ export default function GroupPage(props: { ) : (
- No questions yet. 🦗... Why not add one? + No questions yet. Why not{' '} + + add one? +
) ) : ( @@ -321,18 +343,17 @@ function GroupOverview(props: { return ( - - About {group.name} - {isCreator && } - - -
Created by
- + +
+
Created by
+ +
+ {isCreator && }
Membership @@ -352,6 +373,20 @@ function GroupOverview(props: { )} + {anyoneCanJoin && user && ( + + Sharing + + + Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! + + + + )} ) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index c8f08b25..22fe7661 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -15,6 +15,8 @@ import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' import { GroupMembersList } from 'web/pages/group/[...slugs]' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' +import { SiteLink } from 'web/components/site-link' +import clsx from 'clsx' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) @@ -105,7 +107,7 @@ export default function Groups(props: { 0 ? [ { title: 'My Groups', @@ -202,3 +204,16 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { ) } + +export function GroupLink(props: { group: Group; className?: string }) { + const { group, className } = props + + return ( + + {group.name} + + ) +} diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 0b0186ed..60966756 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -31,7 +31,7 @@ export default function ClaimPage() { url="/send" />
- + <Title text={`Claim M$${manalink.amount} mana`} /> <ManalinkCard defaultMessage={fromUser?.name || 'Enjoy this mana!'} info={info} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a3af0a9a..9b0216b6 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -14,9 +14,6 @@ import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { Answer } from 'common/answer' -import { Comment } from 'web/lib/firebase/comments' -import { getValue } from 'web/lib/firebase/utils' import Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' @@ -38,7 +35,6 @@ import { NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { getContractFromId } from 'web/lib/firebase/contracts' import { CheckIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' @@ -182,7 +178,7 @@ function NotificationGroupItem(props: { className?: string }) { const { notificationGroup, className } = props - const { sourceContractId, notifications } = notificationGroup + const { notifications } = notificationGroup const { sourceContractTitle, sourceContractSlug, @@ -191,28 +187,6 @@ function NotificationGroupItem(props: { const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - const [contract, setContract] = useState<Contract | undefined>(undefined) - - useEffect(() => { - if ( - sourceContractTitle && - sourceContractSlug && - sourceContractCreatorUsername - ) - return - if (sourceContractId) { - getContractFromId(sourceContractId) - .then((contract) => { - if (contract) setContract(contract) - }) - .catch((e) => console.log(e)) - } - }, [ - sourceContractCreatorUsername, - sourceContractId, - sourceContractSlug, - sourceContractTitle, - ]) useEffect(() => { setNotificationsAsSeen(notifications) @@ -240,20 +214,20 @@ function NotificationGroupItem(props: { onClick={() => setExpanded(!expanded)} className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} > - {sourceContractTitle || contract ? ( + {sourceContractTitle ? ( <span> {'Activity on '} <a href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : `/${contract?.creatorUsername}/${contract?.slug}` + : '' } className={ 'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' } > - {sourceContractTitle || contract?.question} + {sourceContractTitle} </a> </span> ) : ( @@ -306,6 +280,7 @@ function NotificationGroupItem(props: { ) } +// TODO: where should we put referral bonus notifications? function NotificationSettings() { const user = useUser() const [notificationSettings, setNotificationSettings] = @@ -455,6 +430,10 @@ function NotificationSettings() { highlight={notificationSettings !== 'none'} label={"Activity on questions you're betting on"} /> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Referral bonuses you've received"} + /> <NotificationSettingLine label={"Activity on questions you've ever bet or commented on"} highlight={notificationSettings === 'all'} @@ -515,7 +494,6 @@ function NotificationItem(props: { const { notification, justSummary } = props const { sourceType, - sourceContractId, sourceId, sourceUserName, sourceUserAvatarUrl, @@ -534,60 +512,15 @@ function NotificationItem(props: { const [defaultNotificationText, setDefaultNotificationText] = useState<string>('') - const [contract, setContract] = useState<Contract | null>(null) - - useEffect(() => { - if ( - !sourceContractId || - (sourceContractSlug && sourceContractCreatorUsername) - ) - return - getContractFromId(sourceContractId) - .then((contract) => { - if (contract) setContract(contract) - }) - .catch((e) => console.log(e)) - }, [ - sourceContractCreatorUsername, - sourceContractId, - sourceContractSlug, - sourceContractTitle, - ]) useEffect(() => { if (sourceText) { setDefaultNotificationText(sourceText) - } else if (!contract || !sourceContractId || !sourceId) return - else if ( - sourceType === 'answer' || - sourceType === 'comment' || - sourceType === 'contract' - ) { - try { - parseOldStyleNotificationText( - sourceId, - sourceContractId, - sourceType, - sourceUpdateType, - setDefaultNotificationText, - contract - ) - } catch (err) { - console.error(err) - } } else if (reasonText) { // Handle arbitrary notifications with reason text here. setDefaultNotificationText(reasonText) } - }, [ - contract, - reasonText, - sourceContractId, - sourceId, - sourceText, - sourceType, - sourceUpdateType, - ]) + }, [reasonText, sourceText]) useEffect(() => { setNotificationsAsSeen([notification]) @@ -596,14 +529,16 @@ function NotificationItem(props: { function getSourceUrl() { if (sourceType === 'follow') return `/${sourceUserUsername}` if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + if ( + sourceContractCreatorUsername && + sourceContractSlug && + sourceType === 'user' + ) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '' )}` - if (!contract) return '' - return `/${contract.creatorUsername}/${ - contract.slug - }#${getSourceIdForLinkComponent(sourceId ?? '')}` } function getSourceIdForLinkComponent(sourceId: string) { @@ -619,38 +554,6 @@ function NotificationItem(props: { } } - async function parseOldStyleNotificationText( - sourceId: string, - sourceContractId: string, - sourceType: 'answer' | 'comment' | 'contract', - sourceUpdateType: notification_source_update_types | undefined, - setText: (text: string) => void, - contract: Contract - ) { - if (sourceType === 'contract') { - if ( - isNotificationAboutContractResolution( - sourceType, - sourceUpdateType, - contract - ) && - contract.resolution - ) - setText(contract.resolution) - else setText(contract.question) - } else if (sourceType === 'answer') { - const answer = await getValue<Answer>( - doc(db, `contracts/${sourceContractId}/answers/`, sourceId) - ) - setText(answer?.text ?? '') - } else { - const comment = await getValue<Comment>( - doc(db, `contracts/${sourceContractId}/comments/`, sourceId) - ) - setText(comment?.text ?? '') - } - } - if (justSummary) { return ( <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> @@ -669,13 +572,13 @@ function NotificationItem(props: { sourceType, reason, sourceUpdateType, - contract, + undefined, true ).replace(' on', '')} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel - contract={contract} + contract={null} defaultText={defaultNotificationText} className={'line-clamp-1'} notification={notification} @@ -717,7 +620,9 @@ function NotificationItem(props: { sourceType, reason, sourceUpdateType, - contract + undefined, + false, + sourceSlug )} <a href={ @@ -725,13 +630,13 @@ function NotificationItem(props: { ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` : sourceType === 'group' && sourceSlug ? `${groupPath(sourceSlug)}` - : `/${contract?.creatorUsername}/${contract?.slug}` + : '' } className={ 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' } > - {contract?.question || sourceContractTitle || sourceTitle} + {sourceContractTitle || sourceTitle} </a> </div> )} @@ -752,7 +657,7 @@ function NotificationItem(props: { </Row> <div className={'mt-1 ml-1 md:text-base'}> <NotificationTextLabel - contract={contract} + contract={null} defaultText={defaultNotificationText} notification={notification} /> @@ -811,6 +716,16 @@ function NotificationTextLabel(props: { </span> ) } + } else if (sourceType === 'user' && sourceText) { + return ( + <span> + As a thank you, we sent you{' '} + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + ! + </span> + ) } else if (sourceType === 'liquidity' && sourceText) { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> @@ -829,7 +744,8 @@ function getReasonForShowingNotification( reason: notification_reason_types, sourceUpdateType: notification_source_update_types | undefined, contract: Contract | undefined | null, - simple?: boolean + simple?: boolean, + sourceSlug?: string ) { let reasonText: string switch (source) { @@ -883,6 +799,12 @@ function getReasonForShowingNotification( case 'group': reasonText = 'added you to the group' break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break default: reasonText = '' } diff --git a/web/tsconfig.json b/web/tsconfig.json index 96cf1311..2f31aa8c 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -16,7 +16,6 @@ "jsx": "preserve", "incremental": true }, - "watchOptions": { "excludeDirectories": [".next"] },