From eedfa1ffea00555c4411b22ba66482e4ec0cc43e Mon Sep 17 00:00:00 2001 From: Sam Peters Date: Wed, 25 Oct 2023 14:34:17 -0500 Subject: [PATCH 1/3] feat: enable quiz completion without reward --- .../dev/apollo-federation/supergraph.graphql | 1 + core/api/src/app/payments/add-earn.ts | 99 ++++++++++++++++--- .../public/root/mutation/quiz-completed.ts | 16 ++- core/api/src/graphql/public/schema.graphql | 1 + .../public/types/object/consumer-account.ts | 16 ++- 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/core/api/dev/apollo-federation/supergraph.graphql b/core/api/dev/apollo-federation/supergraph.graphql index 5f86e3729a..49f0da91f5 100644 --- a/core/api/dev/apollo-federation/supergraph.graphql +++ b/core/api/dev/apollo-federation/supergraph.graphql @@ -309,6 +309,7 @@ type ConsumerAccount implements Account """List the quiz questions of the consumer account""" quiz: [Quiz!]! + quizRewardsEnabled: Boolean! realtimePrice: RealtimePrice! """ diff --git a/core/api/src/app/payments/add-earn.ts b/core/api/src/app/payments/add-earn.ts index 74c14a476b..3a0ae50396 100644 --- a/core/api/src/app/payments/add-earn.ts +++ b/core/api/src/app/payments/add-earn.ts @@ -29,7 +29,13 @@ export const addEarn = async ({ }: { quizQuestionId: string accountId: string -}): Promise => { +}): Promise< + | { + quizQuestion: Quiz + rewardPaymentError?: ApplicationError + } + | ApplicationError +> => { const accountId = checkedToAccountId(accountIdRaw) if (accountId instanceof Error) return accountId @@ -53,12 +59,25 @@ export const addEarn = async ({ const user = await UsersRepository().findById(recipientAccount.kratosUserId) if (user instanceof Error) return user + const isFirstTimeAnsweringQuestion = + await RewardsRepository(accountId).add(quizQuestionId) + if (isFirstTimeAnsweringQuestion instanceof Error) return isFirstTimeAnsweringQuestion + + const quizQuestion: Quiz = { + id: quizQuestionId, + amount: amount, + completed: true, + } + const validatedPhoneMetadata = PhoneMetadataAuthorizer( rewardsConfig.phoneMetadataValidationSettings, ).authorize(user.phoneMetadata) if (validatedPhoneMetadata instanceof Error) { - return new InvalidPhoneForRewardError(validatedPhoneMetadata.name) + return { + quizQuestion, + rewardPaymentError: new InvalidPhoneForRewardError(validatedPhoneMetadata.name), + } } const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id) @@ -67,26 +86,43 @@ export const addEarn = async ({ const validatedIPMetadata = IPMetadataAuthorizer( rewardsConfig.ipMetadataValidationSettings, ).authorize(accountIP.metadata) + if (validatedIPMetadata instanceof Error) { if (validatedIPMetadata instanceof MissingIPMetadataError) - return new InvalidIpMetadataError(validatedIPMetadata) - - if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata - - return new UnknownRepositoryError("add earn error") + return { + quizQuestion, + rewardPaymentError: new InvalidIpMetadataError(validatedIPMetadata), + } + + if (validatedIPMetadata instanceof UnauthorizedIPError) + return { + quizQuestion, + rewardPaymentError: validatedIPMetadata, + } + + return { + quizQuestion, + rewardPaymentError: new UnknownRepositoryError("add earn error"), + } } const recipientWallets = await WalletsRepository().listByAccountId(accountId) - if (recipientWallets instanceof Error) return recipientWallets + if (recipientWallets instanceof Error) + return { + quizQuestion, + rewardPaymentError: recipientWallets, + } const recipientBtcWallet = recipientWallets.find( (wallet) => wallet.currency === WalletCurrency.Btc, ) - if (recipientBtcWallet === undefined) return new NoBtcWalletExistsForAccountError() - const recipientWalletId = recipientBtcWallet.id + if (recipientBtcWallet === undefined) + return { + quizQuestion, + rewardPaymentError: new NoBtcWalletExistsForAccountError(), + } - const shouldGiveReward = await RewardsRepository(accountId).add(quizQuestionId) - if (shouldGiveReward instanceof Error) return shouldGiveReward + const recipientWalletId = recipientBtcWallet.id const payment = await intraledgerPaymentSendWalletIdForBtcWallet({ senderWalletId: funderWalletId, @@ -95,7 +131,42 @@ export const addEarn = async ({ memo: quizQuestionId, senderAccount: funderAccount, }) - if (payment instanceof Error) return payment - return { id: quizQuestionId, earnAmount: amount } + return { + quizQuestion, + rewardPaymentError: payment instanceof Error ? payment : undefined, + } +} + +export const isAccountEligibleForEarnPayment = async ({ + accountId, +}: { + accountId: AccountId +}): Promise => { + const recipientAccount = await AccountsRepository().findById(accountId) + if (recipientAccount instanceof Error) return recipientAccount + + const user = await UsersRepository().findById(recipientAccount.kratosUserId) + if (user instanceof Error) return user + + const rewardsConfig = getRewardsConfig() + + const validatedPhoneMetadata = PhoneMetadataAuthorizer( + rewardsConfig.phoneMetadataValidationSettings, + ).authorize(user.phoneMetadata) + + if (validatedPhoneMetadata instanceof Error) { + return false + } + + const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id) + if (accountIP instanceof Error) return accountIP + + const validatedIPMetadata = IPMetadataAuthorizer( + rewardsConfig.ipMetadataValidationSettings, + ).authorize(accountIP.metadata) + + if (validatedIPMetadata instanceof Error) return false + + return true } diff --git a/core/api/src/graphql/public/root/mutation/quiz-completed.ts b/core/api/src/graphql/public/root/mutation/quiz-completed.ts index 9656e44f15..716568453f 100644 --- a/core/api/src/graphql/public/root/mutation/quiz-completed.ts +++ b/core/api/src/graphql/public/root/mutation/quiz-completed.ts @@ -26,21 +26,19 @@ const QuizCompletedMutation = GT.Field< resolve: async (_, args, { domainAccount }) => { const { id } = args.input - const question = await Payments.addEarn({ + const res = await Payments.addEarn({ quizQuestionId: id, accountId: domainAccount.id, }) - if (question instanceof Error) { - return { errors: [mapAndParseErrorForGqlResponse(question)] } + if (res instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(res)] } } return { - errors: [], - quiz: { - id: question.id, - amount: question.earnAmount, - completed: true, - }, + errors: res.rewardPaymentError + ? [mapAndParseErrorForGqlResponse(res.rewardPaymentError)] + : [], + quiz: res.quizQuestion, } }, }) diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index 17c370aeec..46cfcb7848 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -230,6 +230,7 @@ type ConsumerAccount implements Account { """List the quiz questions of the consumer account""" quiz: [Quiz!]! + quizRewardsEnabled: Boolean! realtimePrice: RealtimePrice! """ diff --git a/core/api/src/graphql/public/types/object/consumer-account.ts b/core/api/src/graphql/public/types/object/consumer-account.ts index 6d7d740bc7..a6d602c4c1 100644 --- a/core/api/src/graphql/public/types/object/consumer-account.ts +++ b/core/api/src/graphql/public/types/object/consumer-account.ts @@ -10,7 +10,7 @@ import CallbackEndpoint from "./callback-endpoint" import { NotificationSettings } from "./notification-settings" -import { Accounts, Prices, Wallets } from "@/app" +import { Accounts, Payments, Prices, Wallets } from "@/app" import { majorToMinorUnit, @@ -158,6 +158,20 @@ const ConsumerAccount = GT.Object({ resolve: (source) => source.quiz, }, + quizRewardsEnabled: { + type: GT.NonNull(GT.Boolean), + resolve: async (source) => { + const rewardsEnabled = await Payments.isAccountEligibleForEarnPayment({ + accountId: source.id, + }) + + if (rewardsEnabled instanceof Error) { + throw mapError(rewardsEnabled) + } + + return rewardsEnabled + }, + }, transactions: { description: "A list of all transactions associated with walletIds optionally passed.", From 2233a3f66bf8656c3b4b5937eed480ca605cba2b Mon Sep 17 00:00:00 2001 From: Sam Peters Date: Wed, 25 Oct 2023 21:44:28 -0500 Subject: [PATCH 2/3] chore: use PartialReturn type --- .../dev/apollo-federation/supergraph.graphql | 1 + core/api/src/app/index.types.d.ts | 24 +++- core/api/src/app/partial-result.ts | 12 +- core/api/src/app/payments/add-earn.ts | 122 ++++++++++++------ .../src/domain/bitcoin/lightning/errors.ts | 3 - core/api/src/domain/errors.ts | 1 - core/api/src/graphql/error-map.ts | 5 - .../root/mutation/ln-invoice-fee-probe.ts | 27 ++-- .../mutation/ln-noamount-invoice-fee-probe.ts | 28 ++-- .../ln-noamount-usd-invoice-fee-probe.ts | 28 ++-- .../root/mutation/ln-usd-invoice-fee-probe.ts | 26 ++-- .../public/root/mutation/quiz-completed.ts | 15 ++- core/api/src/graphql/public/schema.graphql | 1 + .../public/types/object/business-account.ts | 21 ++- .../public/types/object/consumer-account.ts | 20 ++- .../public/types/payload/quiz-completed.ts | 3 + .../graphql/shared/types/object/btc-wallet.ts | 26 ++-- .../graphql/shared/types/object/usd-wallet.ts | 26 ++-- core/api/src/services/mongoose/rewards.ts | 2 +- core/api/src/services/tracing.ts | 6 +- 20 files changed, 211 insertions(+), 186 deletions(-) diff --git a/core/api/dev/apollo-federation/supergraph.graphql b/core/api/dev/apollo-federation/supergraph.graphql index 49f0da91f5..f095da3c3d 100644 --- a/core/api/dev/apollo-federation/supergraph.graphql +++ b/core/api/dev/apollo-federation/supergraph.graphql @@ -1372,6 +1372,7 @@ type QuizCompletedPayload { errors: [Error!]! quiz: Quiz + rewardPaid: Boolean } type QuizQuestion diff --git a/core/api/src/app/index.types.d.ts b/core/api/src/app/index.types.d.ts index 41146e4f33..8341f1be43 100644 --- a/core/api/src/app/index.types.d.ts +++ b/core/api/src/app/index.types.d.ts @@ -1,8 +1,22 @@ -type PartialResult = { - result: T | null - error?: ApplicationError - partialResult: true -} +type PartialResultType = + (typeof import("./partial-result").PartialResultType)[keyof typeof import("./partial-result").PartialResultType] + +type PartialResult = + | { + result: T + error?: undefined + type: typeof import("./partial-result").PartialResultType.Ok + } + | { + result: T + error: ApplicationError + type: typeof import("./partial-result").PartialResultType.Partial + } + | { + result: null + error: ApplicationError + type: typeof import("./partial-result").PartialResultType.Err + } type ValueOf = T[keyof T] diff --git a/core/api/src/app/partial-result.ts b/core/api/src/app/partial-result.ts index 9c10a546ad..f835f5708d 100644 --- a/core/api/src/app/partial-result.ts +++ b/core/api/src/app/partial-result.ts @@ -1,16 +1,22 @@ export const PartialResult = { ok: (result: T): PartialResult => ({ result, - partialResult: true, + type: PartialResultType.Ok, }), partial: (result: T, error: ApplicationError): PartialResult => ({ result, error, - partialResult: true, + type: PartialResultType.Partial, }), err: (error: ApplicationError): PartialResult => ({ result: null, error, - partialResult: true, + type: PartialResultType.Err, }), } + +export const PartialResultType = { + Partial: "Partial", + Ok: "Ok", + Err: "Err", +} as const diff --git a/core/api/src/app/payments/add-earn.ts b/core/api/src/app/payments/add-earn.ts index 3a0ae50396..406da887a6 100644 --- a/core/api/src/app/payments/add-earn.ts +++ b/core/api/src/app/payments/add-earn.ts @@ -1,3 +1,5 @@ +import { PartialResult } from "../partial-result" + import { intraledgerPaymentSendWalletIdForBtcWallet } from "./send-intraledger" import { getRewardsConfig, OnboardingEarn } from "@/config" @@ -30,14 +32,13 @@ export const addEarn = async ({ quizQuestionId: string accountId: string }): Promise< - | { - quizQuestion: Quiz - rewardPaymentError?: ApplicationError - } - | ApplicationError + PartialResult<{ + quiz: Quiz + rewardPaid: boolean + }> > => { const accountId = checkedToAccountId(accountIdRaw) - if (accountId instanceof Error) return accountId + if (accountId instanceof Error) return PartialResult.err(accountId) const rewardsConfig = getRewardsConfig() @@ -45,25 +46,26 @@ export const addEarn = async ({ const quizQuestionId = quizQuestionIdString as QuizQuestionId const amount = OnboardingEarn[quizQuestionId] - if (!amount) return new InvalidQuizQuestionIdError() + if (!amount) return PartialResult.err(new InvalidQuizQuestionIdError()) const funderWalletId = await getFunderWalletId() const funderWallet = await WalletsRepository().findById(funderWalletId) - if (funderWallet instanceof Error) return funderWallet + if (funderWallet instanceof Error) return PartialResult.err(funderWallet) const funderAccount = await AccountsRepository().findById(funderWallet.accountId) - if (funderAccount instanceof Error) return funderAccount + if (funderAccount instanceof Error) return PartialResult.err(funderAccount) const recipientAccount = await AccountsRepository().findById(accountId) - if (recipientAccount instanceof Error) return recipientAccount + if (recipientAccount instanceof Error) return PartialResult.err(recipientAccount) const user = await UsersRepository().findById(recipientAccount.kratosUserId) - if (user instanceof Error) return user + if (user instanceof Error) return PartialResult.err(user) const isFirstTimeAnsweringQuestion = await RewardsRepository(accountId).add(quizQuestionId) - if (isFirstTimeAnsweringQuestion instanceof Error) return isFirstTimeAnsweringQuestion + if (isFirstTimeAnsweringQuestion instanceof Error) + return PartialResult.err(isFirstTimeAnsweringQuestion) - const quizQuestion: Quiz = { + const quiz: Quiz = { id: quizQuestionId, amount: amount, completed: true, @@ -74,14 +76,24 @@ export const addEarn = async ({ ).authorize(user.phoneMetadata) if (validatedPhoneMetadata instanceof Error) { - return { - quizQuestion, - rewardPaymentError: new InvalidPhoneForRewardError(validatedPhoneMetadata.name), - } + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + new InvalidPhoneForRewardError(validatedPhoneMetadata.name), + ) } const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id) - if (accountIP instanceof Error) return accountIP + if (accountIP instanceof Error) + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + new InvalidPhoneForRewardError(accountIP), + ) const validatedIPMetadata = IPMetadataAuthorizer( rewardsConfig.ipMetadataValidationSettings, @@ -89,38 +101,53 @@ export const addEarn = async ({ if (validatedIPMetadata instanceof Error) { if (validatedIPMetadata instanceof MissingIPMetadataError) - return { - quizQuestion, - rewardPaymentError: new InvalidIpMetadataError(validatedIPMetadata), - } + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + new InvalidIpMetadataError(validatedIPMetadata), + ) if (validatedIPMetadata instanceof UnauthorizedIPError) - return { - quizQuestion, - rewardPaymentError: validatedIPMetadata, - } - - return { - quizQuestion, - rewardPaymentError: new UnknownRepositoryError("add earn error"), - } + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + validatedIPMetadata, + ) + + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + new UnknownRepositoryError("add earn error"), + ) } const recipientWallets = await WalletsRepository().listByAccountId(accountId) if (recipientWallets instanceof Error) - return { - quizQuestion, - rewardPaymentError: recipientWallets, - } + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + recipientWallets, + ) const recipientBtcWallet = recipientWallets.find( (wallet) => wallet.currency === WalletCurrency.Btc, ) if (recipientBtcWallet === undefined) - return { - quizQuestion, - rewardPaymentError: new NoBtcWalletExistsForAccountError(), - } + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + new NoBtcWalletExistsForAccountError(), + ) const recipientWalletId = recipientBtcWallet.id @@ -132,10 +159,19 @@ export const addEarn = async ({ senderAccount: funderAccount, }) - return { - quizQuestion, - rewardPaymentError: payment instanceof Error ? payment : undefined, - } + if (payment instanceof Error) + return PartialResult.partial( + { + quiz, + rewardPaid: false, + }, + payment, + ) + + return PartialResult.ok({ + quiz, + rewardPaid: true, + }) } export const isAccountEligibleForEarnPayment = async ({ diff --git a/core/api/src/domain/bitcoin/lightning/errors.ts b/core/api/src/domain/bitcoin/lightning/errors.ts index c9bd4b45cb..5ba3b0bbb5 100644 --- a/core/api/src/domain/bitcoin/lightning/errors.ts +++ b/core/api/src/domain/bitcoin/lightning/errors.ts @@ -50,9 +50,6 @@ export class DestinationMissingDependentFeatureError extends LightningServiceErr export class LookupPaymentTimedOutError extends LightningServiceError { level = ErrorLevel.Critical } -export class InvalidFeeProbeStateError extends LightningServiceError { - level = ErrorLevel.Critical -} export class UnknownRouteNotFoundError extends LightningServiceError { level = ErrorLevel.Critical diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index 1fd4d8006f..f2cbb288a3 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -65,7 +65,6 @@ export class CouldNotFindLnPaymentFromHashError extends CouldNotFindError { export class CouldNotFindAccountFromIdError extends CouldNotFindError {} export class CouldNotFindAccountFromUsernameError extends CouldNotFindError {} export class CouldNotFindAccountFromPhoneError extends CouldNotFindError {} -export class CouldNotFindTransactionsForAccountError extends CouldNotFindError {} export class CouldNotFindAccountFromKratosIdError extends CouldNotFindError {} export class RewardAlreadyPresentError extends DomainError {} diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index d99ae1d4c0..93ff3cdd41 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -96,10 +96,6 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = `The associated lightning invoice could not be found.` return new NotFoundError({ message, logger: baseLogger }) - case "CouldNotFindTransactionsForAccountError": - message = "No transactions found for your account." - return new NotFoundError({ message, logger: baseLogger }) - case "CouldNotFindUserFromPhoneError": message = `User does not exist for phone ${error.message}` return new NotFoundError({ message, logger: baseLogger }) @@ -591,7 +587,6 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "BigIntConversionError": case "BigIntFloatConversionError": case "SafeWrapperError": - case "InvalidFeeProbeStateError": case "LookupPaymentTimedOutError": case "InvalidPubKeyError": case "SkipProbeForPubkeyError": diff --git a/core/api/src/graphql/public/root/mutation/ln-invoice-fee-probe.ts b/core/api/src/graphql/public/root/mutation/ln-invoice-fee-probe.ts index 22a3991e97..4b8c9168fb 100644 --- a/core/api/src/graphql/public/root/mutation/ln-invoice-fee-probe.ts +++ b/core/api/src/graphql/public/root/mutation/ln-invoice-fee-probe.ts @@ -1,7 +1,5 @@ import { normalizePaymentAmount } from "../../../shared/root/mutation" -import { InvalidFeeProbeStateError } from "@/domain/bitcoin/lightning" - import { Payments } from "@/app" import { GT } from "@/graphql/index" @@ -9,6 +7,7 @@ import WalletId from "@/graphql/shared/types/scalar/wallet-id" import SatAmountPayload from "@/graphql/public/types/payload/sat-amount" import LnPaymentRequest from "@/graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" +import { PartialResultType } from "@/app/partial-result" const LnInvoiceFeeProbeInput = GT.Input({ name: "LnInvoiceFeeProbeInput", @@ -43,31 +42,29 @@ const LnInvoiceFeeProbeMutation = GT.Field< if (paymentRequest instanceof Error) return { errors: [{ message: paymentRequest.message }] } - const { result: feeSatAmount, error } = - await Payments.getLightningFeeEstimationForBtcWallet({ - walletId, - uncheckedPaymentRequest: paymentRequest, - }) + const { + result: feeSatAmount, + error, + type, + } = await Payments.getLightningFeeEstimationForBtcWallet({ + walletId, + uncheckedPaymentRequest: paymentRequest, + }) - if (feeSatAmount !== null && error instanceof Error) { + if (type === PartialResultType.Partial) { + error return { errors: [mapAndParseErrorForGqlResponse(error)], ...normalizePaymentAmount(feeSatAmount), } } - if (error instanceof Error) { + if (type === PartialResultType.Err) { return { errors: [mapAndParseErrorForGqlResponse(error)], } } - if (feeSatAmount === null) { - return { - errors: [mapAndParseErrorForGqlResponse(new InvalidFeeProbeStateError())], - } - } - return { errors: [], ...normalizePaymentAmount(feeSatAmount), diff --git a/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-fee-probe.ts b/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-fee-probe.ts index 2abbfc867f..72b5e5f808 100644 --- a/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-fee-probe.ts +++ b/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-fee-probe.ts @@ -1,7 +1,5 @@ import { normalizePaymentAmount } from "../../../shared/root/mutation" -import { InvalidFeeProbeStateError } from "@/domain/bitcoin/lightning" - import { Payments } from "@/app" import { GT } from "@/graphql/index" @@ -10,6 +8,7 @@ import SatAmount from "@/graphql/shared/types/scalar/sat-amount" import SatAmountPayload from "@/graphql/public/types/payload/sat-amount" import LnPaymentRequest from "@/graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" +import { PartialResultType } from "@/app/partial-result" const LnNoAmountInvoiceFeeProbeInput = GT.Input({ name: "LnNoAmountInvoiceFeeProbeInput", @@ -37,32 +36,29 @@ const LnNoAmountInvoiceFeeProbeMutation = GT.Field({ } } - const { result: feeSatAmount, error } = - await Payments.getNoAmountLightningFeeEstimationForBtcWallet({ - walletId, - amount, - uncheckedPaymentRequest: paymentRequest, - }) + const { + result: feeSatAmount, + error, + type, + } = await Payments.getNoAmountLightningFeeEstimationForBtcWallet({ + walletId, + amount, + uncheckedPaymentRequest: paymentRequest, + }) - if (feeSatAmount !== null && error instanceof Error) { + if (type === PartialResultType.Partial) { return { errors: [mapAndParseErrorForGqlResponse(error)], ...normalizePaymentAmount(feeSatAmount), } } - if (error instanceof Error) { + if (type === PartialResultType.Err) { return { errors: [mapAndParseErrorForGqlResponse(error)], } } - if (feeSatAmount === null) { - return { - errors: [mapAndParseErrorForGqlResponse(new InvalidFeeProbeStateError())], - } - } - return { errors: [], ...normalizePaymentAmount(feeSatAmount), diff --git a/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts b/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts index acdb977f87..72c417d345 100644 --- a/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts +++ b/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-fee-probe.ts @@ -1,7 +1,5 @@ import { normalizePaymentAmount } from "../../../shared/root/mutation" -import { InvalidFeeProbeStateError } from "@/domain/bitcoin/lightning" - import { Payments } from "@/app" import { GT } from "@/graphql/index" @@ -10,6 +8,7 @@ import CentAmount from "@/graphql/public/types/scalar/cent-amount" import CentAmountPayload from "@/graphql/public/types/payload/cent-amount" import LnPaymentRequest from "@/graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" +import { PartialResultType } from "@/app/partial-result" const LnNoAmountUsdInvoiceFeeProbeInput = GT.Input({ name: "LnNoAmountUsdInvoiceFeeProbeInput", @@ -37,32 +36,29 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ } } - const { result: feeSatAmount, error } = - await Payments.getNoAmountLightningFeeEstimationForUsdWallet({ - walletId, - amount, - uncheckedPaymentRequest: paymentRequest, - }) + const { + result: feeSatAmount, + error, + type, + } = await Payments.getNoAmountLightningFeeEstimationForUsdWallet({ + walletId, + amount, + uncheckedPaymentRequest: paymentRequest, + }) - if (feeSatAmount !== null && error instanceof Error) { + if (type === PartialResultType.Partial) { return { errors: [mapAndParseErrorForGqlResponse(error)], ...normalizePaymentAmount(feeSatAmount), } } - if (error instanceof Error) { + if (type === PartialResultType.Err) { return { errors: [mapAndParseErrorForGqlResponse(error)], } } - if (feeSatAmount === null) { - return { - errors: [mapAndParseErrorForGqlResponse(new InvalidFeeProbeStateError())], - } - } - return { errors: [], ...normalizePaymentAmount(feeSatAmount), diff --git a/core/api/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts b/core/api/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts index b65b1b2866..2e29955db2 100644 --- a/core/api/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts +++ b/core/api/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts @@ -1,7 +1,5 @@ import { normalizePaymentAmount } from "../../../shared/root/mutation" -import { InvalidFeeProbeStateError } from "@/domain/bitcoin/lightning" - import { Payments } from "@/app" import { GT } from "@/graphql/index" @@ -11,6 +9,7 @@ import LnPaymentRequest from "@/graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" import { checkedToWalletId } from "@/domain/wallets" +import { PartialResultType } from "@/app/partial-result" const LnUsdInvoiceFeeProbeInput = GT.Input({ name: "LnUsdInvoiceFeeProbeInput", @@ -52,31 +51,28 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< if (walletIdChecked instanceof Error) return { errors: [mapAndParseErrorForGqlResponse(walletIdChecked)] } - const { result: feeSatAmount, error } = - await Payments.getLightningFeeEstimationForUsdWallet({ - walletId, - uncheckedPaymentRequest: paymentRequest, - }) + const { + result: feeSatAmount, + error, + type, + } = await Payments.getLightningFeeEstimationForUsdWallet({ + walletId, + uncheckedPaymentRequest: paymentRequest, + }) - if (feeSatAmount !== null && error instanceof Error) { + if (type === PartialResultType.Partial) { return { errors: [mapAndParseErrorForGqlResponse(error)], ...normalizePaymentAmount(feeSatAmount), } } - if (error instanceof Error) { + if (type === PartialResultType.Err) { return { errors: [mapAndParseErrorForGqlResponse(error)], } } - if (feeSatAmount === null) { - return { - errors: [mapAndParseErrorForGqlResponse(new InvalidFeeProbeStateError())], - } - } - return { errors: [], ...normalizePaymentAmount(feeSatAmount), diff --git a/core/api/src/graphql/public/root/mutation/quiz-completed.ts b/core/api/src/graphql/public/root/mutation/quiz-completed.ts index 716568453f..33e96d3eb6 100644 --- a/core/api/src/graphql/public/root/mutation/quiz-completed.ts +++ b/core/api/src/graphql/public/root/mutation/quiz-completed.ts @@ -1,4 +1,5 @@ import { Payments } from "@/app" +import { PartialResultType } from "@/app/partial-result" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" import { GT } from "@/graphql/index" @@ -26,19 +27,19 @@ const QuizCompletedMutation = GT.Field< resolve: async (_, args, { domainAccount }) => { const { id } = args.input - const res = await Payments.addEarn({ + const { result, error, type } = await Payments.addEarn({ quizQuestionId: id, accountId: domainAccount.id, }) - if (res instanceof Error) { - return { errors: [mapAndParseErrorForGqlResponse(res)] } + + if (type === PartialResultType.Err) { + return { errors: [mapAndParseErrorForGqlResponse(error)] } } return { - errors: res.rewardPaymentError - ? [mapAndParseErrorForGqlResponse(res.rewardPaymentError)] - : [], - quiz: res.quizQuestion, + errors: error ? [mapAndParseErrorForGqlResponse(error)] : [], + quiz: result.quiz, + rewardPaid: result.rewardPaid, } }, }) diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index 46cfcb7848..71f6d97325 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -1080,6 +1080,7 @@ input QuizCompletedInput { type QuizCompletedPayload { errors: [Error!]! quiz: Quiz + rewardPaid: Boolean } type QuizQuestion { diff --git a/core/api/src/graphql/public/types/object/business-account.ts b/core/api/src/graphql/public/types/object/business-account.ts index d323a29900..85146362c5 100644 --- a/core/api/src/graphql/public/types/object/business-account.ts +++ b/core/api/src/graphql/public/types/object/business-account.ts @@ -25,8 +25,8 @@ import { SAT_PRICE_PRECISION_OFFSET, USD_PRICE_PRECISION_OFFSET, } from "@/domain/fiat" -import { CouldNotFindTransactionsForAccountError } from "@/domain/errors" import { Accounts, Prices, Wallets } from "@/app" +import { PartialResultType } from "@/app/partial-result" const BusinessAccount = GT.Object({ name: "BusinessAccount", @@ -137,18 +137,15 @@ const BusinessAccount = GT.Object({ walletIds = wallets.map((wallet) => wallet.id) } - const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ - account: source, - walletIds, - paginationArgs, - }) - if (error instanceof Error) { - throw mapError(error) - } + const { result, error, type } = + await Accounts.getTransactionsForAccountByWalletIds({ + account: source, + walletIds, + paginationArgs, + }) - if (!result?.slice) { - const nullError = new CouldNotFindTransactionsForAccountError() - throw mapError(nullError) + if (type !== PartialResultType.Ok) { + throw mapError(error) } return connectionFromPaginatedArray( diff --git a/core/api/src/graphql/public/types/object/consumer-account.ts b/core/api/src/graphql/public/types/object/consumer-account.ts index a6d602c4c1..6759027907 100644 --- a/core/api/src/graphql/public/types/object/consumer-account.ts +++ b/core/api/src/graphql/public/types/object/consumer-account.ts @@ -17,7 +17,6 @@ import { SAT_PRICE_PRECISION_OFFSET, USD_PRICE_PRECISION_OFFSET, } from "@/domain/fiat" -import { CouldNotFindTransactionsForAccountError } from "@/domain/errors" import { GT } from "@/graphql/index" import { mapError } from "@/graphql/error-map" @@ -36,6 +35,7 @@ import DisplayCurrency from "@/graphql/shared/types/scalar/display-currency" import { WalletsRepository } from "@/services/mongoose" import { listEndpoints } from "@/app/callback" +import { PartialResultType } from "@/app/partial-result" const ConsumerAccount = GT.Object({ name: "ConsumerAccount", @@ -198,21 +198,17 @@ const ConsumerAccount = GT.Object({ walletIds = wallets.map((wallet) => wallet.id) } - const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ - account: source, - walletIds, - paginationArgs, - }) + const { result, error, type } = + await Accounts.getTransactionsForAccountByWalletIds({ + account: source, + walletIds, + paginationArgs, + }) - if (error instanceof Error) { + if (type !== PartialResultType.Ok) { throw mapError(error) } - if (!result?.slice) { - const nullError = new CouldNotFindTransactionsForAccountError() - throw mapError(nullError) - } - return connectionFromPaginatedArray( result.slice, result.total, diff --git a/core/api/src/graphql/public/types/payload/quiz-completed.ts b/core/api/src/graphql/public/types/payload/quiz-completed.ts index 0e6ba19580..25e208d672 100644 --- a/core/api/src/graphql/public/types/payload/quiz-completed.ts +++ b/core/api/src/graphql/public/types/payload/quiz-completed.ts @@ -12,6 +12,9 @@ const QuizCompletedPayload = GT.Object({ quiz: { type: Quiz, }, + rewardPaid: { + type: GT.Boolean, + }, }), }) diff --git a/core/api/src/graphql/shared/types/object/btc-wallet.ts b/core/api/src/graphql/shared/types/object/btc-wallet.ts index 15c8d61d85..33e93d5330 100644 --- a/core/api/src/graphql/shared/types/object/btc-wallet.ts +++ b/core/api/src/graphql/shared/types/object/btc-wallet.ts @@ -24,6 +24,7 @@ import { mapError } from "@/graphql/error-map" import { Wallets } from "@/app" import { WalletCurrency as WalletCurrencyDomain } from "@/domain/shared" +import { PartialResultType } from "@/app/partial-result" const BtcWallet = GT.Object({ name: "BTCWallet", @@ -73,17 +74,15 @@ const BtcWallet = GT.Object({ throw paginationArgs } - const { result, error } = await Wallets.getTransactionsForWallets({ + const { result, error, type } = await Wallets.getTransactionsForWallets({ wallets: [source], paginationArgs, }) - if (error instanceof Error) { + + if (type !== PartialResultType.Ok) { throw mapError(error) } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - return connectionFromPaginatedArray( result.slice, result.total, @@ -110,18 +109,17 @@ const BtcWallet = GT.Object({ const { address } = args if (address instanceof Error) throw address - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ - wallets: [source], - addresses: [address], - paginationArgs, - }) - if (error instanceof Error) { + const { result, error, type } = + await Wallets.getTransactionsForWalletsByAddresses({ + wallets: [source], + addresses: [address], + paginationArgs, + }) + + if (type !== PartialResultType.Ok) { throw mapError(error) } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - return connectionFromPaginatedArray( result.slice, result.total, diff --git a/core/api/src/graphql/shared/types/object/usd-wallet.ts b/core/api/src/graphql/shared/types/object/usd-wallet.ts index 93291623f8..87c3199430 100644 --- a/core/api/src/graphql/shared/types/object/usd-wallet.ts +++ b/core/api/src/graphql/shared/types/object/usd-wallet.ts @@ -24,6 +24,7 @@ import { mapError } from "@/graphql/error-map" import { Wallets } from "@/app" import { WalletCurrency as WalletCurrencyDomain } from "@/domain/shared" +import { PartialResultType } from "@/app/partial-result" const UsdWallet = GT.Object({ name: "UsdWallet", @@ -72,17 +73,15 @@ const UsdWallet = GT.Object({ throw paginationArgs } - const { result, error } = await Wallets.getTransactionsForWallets({ + const { result, error, type } = await Wallets.getTransactionsForWallets({ wallets: [source], paginationArgs, }) - if (error instanceof Error) { + + if (type !== PartialResultType.Ok) { throw mapError(error) } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - return connectionFromPaginatedArray( result.slice, result.total, @@ -108,18 +107,17 @@ const UsdWallet = GT.Object({ const { address } = args if (address instanceof Error) throw address - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ - wallets: [source], - addresses: [address], - paginationArgs, - }) - if (error instanceof Error) { + const { result, error, type } = + await Wallets.getTransactionsForWalletsByAddresses({ + wallets: [source], + addresses: [address], + paginationArgs, + }) + + if (type !== PartialResultType.Ok) { throw mapError(error) } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error - return connectionFromPaginatedArray( result.slice, result.total, diff --git a/core/api/src/services/mongoose/rewards.ts b/core/api/src/services/mongoose/rewards.ts index f4658dd182..db9c640d63 100644 --- a/core/api/src/services/mongoose/rewards.ts +++ b/core/api/src/services/mongoose/rewards.ts @@ -9,7 +9,7 @@ export const RewardsRepository = (accountId: AccountId) => { // by default, mongodb return the previous state before the update const oldState = await Account.findOneAndUpdate( { id: accountId }, - { $push: { earn: quizQuestionId } }, + { $addToSet: { earn: quizQuestionId } }, // { upsert: true }, ) diff --git a/core/api/src/services/tracing.ts b/core/api/src/services/tracing.ts index 1d682e7d05..bc9dd446b8 100644 --- a/core/api/src/services/tracing.ts +++ b/core/api/src/services/tracing.ts @@ -430,8 +430,7 @@ export const wrapToRunInSpan = < const ret = fn(...args) if (ret instanceof Error) recordException(span, ret) const partialRet = ret as PartialResult - if (partialRet?.partialResult && partialRet?.error) - recordException(span, partialRet.error) + if (partialRet?.error) recordException(span, partialRet.error) span.end() return ret } catch (error) { @@ -487,8 +486,7 @@ export const wrapAsyncToRunInSpan = < const ret = await fn(...args) if (ret instanceof Error) recordException(span, ret) const partialRet = ret as PartialResult - if (partialRet?.partialResult && partialRet?.error) - recordException(span, partialRet.error) + if (partialRet?.error) recordException(span, partialRet.error) span.end() return ret } catch (error) { From 0945db931029b44d55eac6297247b6fecef6434f Mon Sep 17 00:00:00 2001 From: Sam Peters Date: Thu, 26 Oct 2023 20:20:10 -0500 Subject: [PATCH 3/3] chore: add earn e2e test --- core/api/test/bats/earn.bats | 42 +++++++++++++++++++ .../test/bats/gql/account-quiz-completed.gql | 13 ++++++ core/api/test/bats/gql/account-quiz.gql | 14 +++++++ 3 files changed, 69 insertions(+) create mode 100644 core/api/test/bats/earn.bats create mode 100644 core/api/test/bats/gql/account-quiz-completed.gql create mode 100644 core/api/test/bats/gql/account-quiz.gql diff --git a/core/api/test/bats/earn.bats b/core/api/test/bats/earn.bats new file mode 100644 index 0000000000..fab7d06d63 --- /dev/null +++ b/core/api/test/bats/earn.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats + +load "helpers/setup-and-teardown" + +setup_file() { + clear_cache + reset_redis + + bitcoind_init + start_trigger + start_server + + initialize_user_from_onchain "$ALICE_TOKEN_NAME" "$ALICE_PHONE" "$CODE" +} + +teardown_file() { + stop_trigger + stop_server +} + +@test "earn: mark quiz completed" { + token_name="$ALICE_TOKEN_NAME" + question_id="walletDownloaded" + + # Ensure quiz rewards are disabled and question is not completed + exec_graphql "$token_name" 'account-quiz' + rewards_enabled="$(graphql_output '.data.me.defaultAccount.quizRewardsEnabled')" + [[ "$rewards_enabled" == "false" ]] || exit 1 + quiz_question_completed="$(graphql_output ".data.me.defaultAccount.quiz[] | select(.id == \"$question_id\") | .completed")" + [[ "$quiz_question_completed" == "false" ]] || exit 1 + + # Mark question as completed + variables=$( + jq -n \ + '{input: { id: "'"$question_id"'" }}') + + exec_graphql "$token_name" 'account-quiz-completed' "$variables" + quiz_question_completed="$(graphql_output '.data.quizCompleted.quiz.completed')" + [[ "$quiz_question_completed" == "true" ]] || exit 1 + reward_paid="$(graphql_output ".data.quizCompleted.rewardPaid")" + [[ "$reward_paid" == "false" ]] || exit 1 +} diff --git a/core/api/test/bats/gql/account-quiz-completed.gql b/core/api/test/bats/gql/account-quiz-completed.gql new file mode 100644 index 0000000000..567b7617b1 --- /dev/null +++ b/core/api/test/bats/gql/account-quiz-completed.gql @@ -0,0 +1,13 @@ +mutation accountQuizCompleted($input: QuizCompletedInput!) { + quizCompleted(input: $input) { + quiz { + amount + completed + id + } + errors { + message + } + rewardPaid + } +} diff --git a/core/api/test/bats/gql/account-quiz.gql b/core/api/test/bats/gql/account-quiz.gql new file mode 100644 index 0000000000..c0dfe412ae --- /dev/null +++ b/core/api/test/bats/gql/account-quiz.gql @@ -0,0 +1,14 @@ +query me { + me { + defaultAccount { + ... on ConsumerAccount { + quizRewardsEnabled + quiz { + id + completed + } + } + id + } + } +}