From c1ca1471a1705ca2501af746d6a9df4a44604360 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sat, 9 Jul 2022 00:26:56 -0700 Subject: [PATCH] Migrate createAnswer function to v2 (#634) * Migrate createAnswer function to v2 * Remove unhelpful toString on APIError --- functions/src/create-answer.ts | 185 ++++++++---------- functions/src/index.ts | 2 +- .../answers/create-answer-panel.tsx | 25 +-- web/lib/firebase/api-call.ts | 6 +- web/lib/firebase/fn-call.ts | 10 - 5 files changed, 101 insertions(+), 127 deletions(-) diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index cf3867b0..2abaf44d 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' @@ -7,122 +7,103 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' +import { APIError, newEndpoint, validate } from './api' -export const createAnswer = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - contractId: string - amount: number - text: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string().max(MAX_ANSWER_LENGTH), + amount: z.number().gt(0), + text: z.string(), +}) - const { contractId, amount, text } = data +const opts = { secrets: ['MAILGUN_KEY'] } - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } +export const createanswer = newEndpoint(opts, async (req, auth) => { + const { contractId, amount, text } = validate(bodySchema, req.body) - if (!text || typeof text !== 'string' || text.length > MAX_ANSWER_LENGTH) - return { status: 'error', message: 'Invalid text' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // Run as transaction to prevent race conditions. - const result = await firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // Run as transaction to prevent race conditions. + const answer = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'FREE_RESPONSE') - return { - status: 'error', - message: 'Requires a free response contract', - } + if (contract.outcomeType !== 'FREE_RESPONSE') + throw new APIError(400, 'Requires a free response contract') - const { closeTime, volume } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime, volume } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - const [lastAnswer] = await getValues( - firestore - .collection(`contracts/${contractId}/answers`) - .orderBy('number', 'desc') - .limit(1) - ) + const [lastAnswer] = await getValues( + firestore + .collection(`contracts/${contractId}/answers`) + .orderBy('number', 'desc') + .limit(1) + ) - if (!lastAnswer) - return { status: 'error', message: 'Could not fetch last answer' } + if (!lastAnswer) throw new APIError(500, 'Could not fetch last answer') - const number = lastAnswer.number + 1 - const id = `${number}` + const number = lastAnswer.number + 1 + const id = `${number}` - const newAnswerDoc = firestore - .collection(`contracts/${contractId}/answers`) - .doc(id) + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc(id) - const answerId = newAnswerDoc.id - const { username, name, avatarUrl } = user + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user - const answer: Answer = { - id, - number, - contractId, - createdTime: Date.now(), - userId: user.id, - username, - name, - avatarUrl, - text, - } - transaction.create(newAnswerDoc, answer) - - const loanAmount = 0 - - const { newBet, newPool, newTotalShares, newTotalBets } = - getNewMultiBetInfo(answerId, amount, contract, loanAmount) - - const newBalance = user.balance - amount - const betDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() - transaction.create(betDoc, { - id: betDoc.id, - userId: user.id, - ...newBet, - }) - transaction.update(userDoc, { balance: newBalance }) - transaction.update(contractDoc, { - pool: newPool, - totalShares: newTotalShares, - totalBets: newTotalBets, - answers: [...(contract.answers ?? []), answer], - volume: volume + amount, - }) - - return { status: 'success', answerId, betId: betDoc.id, answer } - }) - - const { answer } = result - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - - return result + const answer: Answer = { + id, + number, + contractId, + createdTime: Date.now(), + userId: user.id, + username, + name, + avatarUrl, + text, } - ) + transaction.create(newAnswerDoc, answer) + + const loanAmount = 0 + + const { newBet, newPool, newTotalShares, newTotalBets } = + getNewMultiBetInfo(answerId, amount, contract, loanAmount) + + const newBalance = user.balance - amount + const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() + transaction.create(betDoc, { + id: betDoc.id, + userId: user.id, + ...newBet, + }) + transaction.update(userDoc, { balance: newBalance }) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + answers: [...(contract.answers ?? []), answer], + volume: volume + amount, + }) + + return answer + }) + + const contract = await getContract(contractId) + + if (answer && contract) await sendNewAnswerEmail(answer, contract) + + return answer +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 7c839396..34fceaa7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,7 +6,6 @@ admin.initializeApp() export * from './transact' export * from './stripe' export * from './create-user' -export * from './create-answer' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' @@ -30,6 +29,7 @@ export * from './on-create-txn' // v2 export * from './health' export * from './change-user-info' +export * from './create-answer' export * from './place-bet' export * from './sell-bet' export * from './sell-shares' diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ed9012c9..41745b09 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -6,7 +6,7 @@ import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { createAnswer } from 'web/lib/firebase/fn-call' +import { APIError, createAnswer } from 'web/lib/firebase/api-call' import { Row } from '../layout/row' import { formatMoney, @@ -46,20 +46,23 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (canSubmit) { setIsSubmitting(true) - const result = await createAnswer({ - contractId: contract.id, - text, - amount: betAmount, - }).then((r) => r.data) - - setIsSubmitting(false) - - if (result.status === 'success') { + try { + await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }) setText('') setBetAmount(10) setAmountError(undefined) setPossibleDuplicateAnswer(undefined) - } else setAmountError(result.message) + } catch (e) { + if (e instanceof APIError) { + setAmountError(e.toString()) + } + } + + setIsSubmitting(false) } } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 04c6b7ce..ef0cad1e 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -10,9 +10,6 @@ export class APIError extends Error { this.name = 'APIError' this.details = details } - toString() { - return this.name - } } export async function call(url: string, method: string, params: any) { @@ -53,6 +50,9 @@ export function getFunctionUrl(name: string) { } } +export function createAnswer(params: any) { + return call(getFunctionUrl('createanswer'), 'POST', params) +} export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 6867b5bb..27a5e8f3 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -14,16 +14,6 @@ export const transact = cloudFunction< { status: 'error' | 'success'; message?: string; txn?: Txn } >('transact') -export const createAnswer = cloudFunction< - { contractId: string; text: string; amount: number }, - { - status: 'error' | 'success' - message?: string - answerId?: string - betId?: string - } ->('createAnswer') - export const createUser: () => Promise = () => { const local = safeLocalStorage() let deviceToken = local?.getItem('device-token')