diff --git a/bats/core/api/quiz.bats b/bats/core/api/quiz.bats index 1f3bcab4d8..0224760b25 100644 --- a/bats/core/api/quiz.bats +++ b/bats/core/api/quiz.bats @@ -16,7 +16,7 @@ setup_file() { "10000" } -@test "quiz: completes a quiz question and gets paid once" { +@test "quiz: completes a quiz question and gets paid once - legacy mutation" { token_name="alice" question_id="sat" @@ -78,3 +78,84 @@ setup_file() { ') [[ "$btc_balance_after_retry" == "$btc_balance_after_quiz" ]] || exit 1 } + +@test "quiz: completes a quiz question and gets paid once - time based quiz mutation" { + token_name="alice" + question_id="whatIsBitcoin" + + # Check initial balance + exec_graphql $token_name 'wallets-for-account' + btc_initial_balance=$(graphql_output ' + .data.me.defaultAccount.wallets[] + | select(.walletCurrency == "BTC") + .balance + ') + + exec_graphql $token_name 'quiz' + completed=$(graphql_output '.data.me.defaultAccount.quiz' | jq '.[] | select(.id == "whatIsBitcoin") | .completed') + [[ "${completed}" == "false" ]] || exit 1 + + # Do quiz + variables=$( + jq -n \ + --arg question_id "$question_id" \ + '{input: {id: $question_id}}' + ) + exec_graphql "$token_name" 'quiz-claim' "$variables" + quizzes=$(graphql_output '.data.quizClaim.quizzes') + [[ "${quizzes}" != "null" ]] || exit 1 + + quiz_completed=$(graphql_output '.data.quizClaim.quizzes' | jq '.[] | select(.id == "whatIsBitcoin") | .completed') + [[ "${quiz_completed}" == "true" ]] || exit 1 + + exec_graphql $token_name 'quiz' + completed=$(graphql_output '.data.me.defaultAccount.quiz' | jq '.[] | select(.id == "whatIsBitcoin") | .completed') + [[ "${completed}" == "true" ]] || exit 1 + + # Check balance after complete + exec_graphql $token_name 'wallets-for-account' + btc_balance_after_quiz=$(graphql_output ' + .data.me.defaultAccount.wallets[] + | select(.walletCurrency == "BTC") + .balance + ') + [[ "$btc_balance_after_quiz" -gt "$btc_initial_balance" ]] || exit 1 + + # Check memo + exec_graphql "$token_name" 'transactions' '{"first": 1}' + txn_memo=$(graphql_output '.data.me.defaultAccount.transactions.edges[0].node.memo') + [[ "${txn_memo}" == "${question_id}" ]] || exit 1 + + # Retry quiz + exec_graphql "$token_name" 'quiz-claim' "$variables" + errors=$(graphql_output '.data.quizClaim.errors') + [[ "${errors}" != "null" ]] || exit 1 + error_msg=$(graphql_output '.data.quizClaim.errors[0].message') + [[ "${error_msg}" =~ "already claimed" ]] || exit 1 + + # Check balance after retry + exec_graphql $token_name 'wallets-for-account' + btc_balance_after_retry=$(graphql_output ' + .data.me.defaultAccount.wallets[] + | select(.walletCurrency == "BTC") + .balance + ') + [[ "$btc_balance_after_retry" == "$btc_balance_after_quiz" ]] || exit 1 + + # Section 1 quiz should not be claimable + + # Do quiz + question_id="coincidenceOfWants" + + variables=$( + jq -n \ + --arg question_id "$question_id" \ + '{input: {id: $question_id}}' + ) + + exec_graphql "$token_name" 'quiz-claim' "$variables" + errors=$(graphql_output '.data.quizClaim.errors') + [[ "${errors}" != "null" ]] || exit 1 + error_msg=$(graphql_output '.data.quizClaim.errors[0].code') + [[ "${error_msg}" =~ "QUIZ_CLAIMED_TOO_EARLY" ]] || exit 1 +} diff --git a/bats/gql/quiz-claim.gql b/bats/gql/quiz-claim.gql new file mode 100644 index 0000000000..a581b2e5cd --- /dev/null +++ b/bats/gql/quiz-claim.gql @@ -0,0 +1,15 @@ +mutation quizClaim($input: QuizClaimInput!) { + quizClaim(input: $input) { + quizzes { + amount + completed + id + notBefore + } + errors { + code + message + path + } + } +} diff --git a/bats/gql/quiz.gql b/bats/gql/quiz.gql index ebd2395d1f..b93946e577 100644 --- a/bats/gql/quiz.gql +++ b/bats/gql/quiz.gql @@ -8,8 +8,9 @@ query myQuizQuestions { id amount completed + notBefore } } } } -} \ No newline at end of file +} diff --git a/core/api/dev/apollo-federation/supergraph.graphql b/core/api/dev/apollo-federation/supergraph.graphql index 19de7dd690..bcb5800a00 100644 --- a/core/api/dev/apollo-federation/supergraph.graphql +++ b/core/api/dev/apollo-federation/supergraph.graphql @@ -723,6 +723,19 @@ enum link__Purpose { EXECUTION } +input LnAddressPaymentSendInput + @join__type(graph: PUBLIC) +{ + """Amount in satoshis.""" + amount: SatAmount! + + """Lightning address to send to.""" + lnAddress: String! + + """Wallet ID to send bitcoin from.""" + walletId: WalletId! +} + type LnInvoice implements Invoice @join__implements(graph: PUBLIC, interface: "Invoice") @join__type(graph: PUBLIC) @@ -929,6 +942,19 @@ type LnUpdate walletId: WalletId! @deprecated(reason: "Deprecated in favor of transaction") } +input LnurlPaymentSendInput + @join__type(graph: PUBLIC) +{ + """Amount in satoshis.""" + amount: SatAmount! + + """Lnurl string to send to.""" + lnurl: String! + + """Wallet ID to send bitcoin from.""" + walletId: WalletId! +} + input LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput @join__type(graph: PUBLIC) { @@ -1054,6 +1080,9 @@ type Mutation """ intraLedgerUsdPaymentSend(input: IntraLedgerUsdPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC) + """Sends a payment to a lightning address.""" + lnAddressPaymentSend(input: LnAddressPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC) + """ Returns a lightning invoice for an associated wallet. When invoice is paid the value will be credited to a BTC wallet. @@ -1130,13 +1159,17 @@ type Mutation """ lnUsdInvoiceCreateOnBehalfOfRecipient(input: LnUsdInvoiceCreateOnBehalfOfRecipientInput!): LnInvoicePayload! @join__field(graph: PUBLIC) lnUsdInvoiceFeeProbe(input: LnUsdInvoiceFeeProbeInput!): SatAmountPayload! @join__field(graph: PUBLIC) + + """Sends a payment to a lightning address.""" + lnurlPaymentSend(input: LnurlPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC) onChainAddressCreate(input: OnChainAddressCreateInput!): OnChainAddressPayload! @join__field(graph: PUBLIC) onChainAddressCurrent(input: OnChainAddressCurrentInput!): OnChainAddressPayload! @join__field(graph: PUBLIC) onChainPaymentSend(input: OnChainPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC) onChainPaymentSendAll(input: OnChainPaymentSendAllInput!): PaymentSendPayload! @join__field(graph: PUBLIC) onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC) onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! @join__field(graph: PUBLIC) - quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @join__field(graph: PUBLIC) + quizClaim(input: QuizClaimInput!): QuizClaimPayload! @join__field(graph: PUBLIC) + quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @join__field(graph: PUBLIC) @deprecated(reason: "Use quizClaim instead") userContactUpdateAlias(input: UserContactUpdateAliasInput!): UserContactUpdateAliasPayload! @join__field(graph: PUBLIC) @deprecated(reason: "will be moved to AccountContact") userEmailDelete: UserEmailDeletePayload! @join__field(graph: PUBLIC) userEmailRegistrationInitiate(input: UserEmailRegistrationInitiateInput!): UserEmailRegistrationInitiatePayload! @join__field(graph: PUBLIC) @@ -1490,6 +1523,20 @@ type Quiz amount: SatAmount! completed: Boolean! id: ID! + notBefore: Timestamp +} + +input QuizClaimInput + @join__type(graph: PUBLIC) +{ + id: ID! +} + +type QuizClaimPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + quizzes: [Quiz!]! } input QuizCompletedInput diff --git a/core/api/src/app/quiz/add.ts b/core/api/src/app/quiz/claim.ts similarity index 52% rename from core/api/src/app/quiz/add.ts rename to core/api/src/app/quiz/claim.ts index f289c1cd2b..acb083272f 100644 --- a/core/api/src/app/quiz/add.ts +++ b/core/api/src/app/quiz/claim.ts @@ -1,6 +1,8 @@ import { intraledgerPaymentSendWalletIdForBtcWallet } from "../payments/send-intraledger" -import { QuizzesValue } from "@/domain/earn" +import { listQuizzesByAccountId } from "./list" + +import { QuizzesValue } from "@/domain/quiz" import { getQuizzesConfig } from "@/config" @@ -12,8 +14,7 @@ import { MissingIPMetadataError, NoBtcWalletExistsForAccountError, NotEnoughBalanceForQuizError, - UnauthorizedIPError, - UnknownRepositoryError, + QuizClaimedTooEarlyError, } from "@/domain/errors" import { WalletCurrency } from "@/domain/shared" import { RateLimitConfig } from "@/domain/rate-limit" @@ -33,7 +34,14 @@ import { consumeLimiter } from "@/services/rate-limit" import { getFunderWalletId } from "@/services/ledger/caching" import { AccountsIpsRepository } from "@/services/mongoose/accounts-ips" -export const completeQuiz = async ({ +type ClaimQuizResult = { + id: QuizQuestionId + amount: Satoshis + completed: boolean + notBefore: Date | undefined +}[] + +export const claimQuizLegacy = async ({ quizQuestionId: quizQuestionIdString, accountId: accountIdRaw, ip, @@ -41,7 +49,7 @@ export const completeQuiz = async ({ quizQuestionId: string accountId: string ip: IpAddress | undefined -}): Promise => { +}): Promise => { const check = await checkAddQuizAttemptPerIpLimits(ip) if (check instanceof Error) return check @@ -86,11 +94,12 @@ export const completeQuiz = async ({ if (validatedIPMetadata instanceof MissingIPMetadataError) return new InvalidIpMetadataError(validatedIPMetadata) - if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata - - return new UnknownRepositoryError("add quiz error") + return validatedIPMetadata } + const quizzesBefore = await listQuizzesByAccountId(accountId) + if (quizzesBefore instanceof Error) return quizzesBefore + const recipientWallets = await WalletsRepository().listByAccountId(accountId) if (recipientWallets instanceof Error) return recipientWallets @@ -121,7 +130,10 @@ export const completeQuiz = async ({ }) if (payment instanceof Error) return payment - return { id: quizId, earnAmount: amount } + const quizzesAfter = await listQuizzesByAccountId(accountId) + if (quizzesAfter instanceof Error) return quizzesAfter + + return quizzesAfter } const checkAddQuizAttemptPerIpLimits = async ( @@ -135,6 +147,106 @@ const checkAddQuizAttemptPerIpLimits = async ( }) } +export const claimQuiz = async ({ + quizQuestionId: quizQuestionIdString, + accountId: accountIdRaw, + ip, +}: { + quizQuestionId: string + accountId: string + ip: IpAddress | undefined +}): Promise => { + const check = await checkAddQuizAttemptPerIpLimits(ip) + if (check instanceof Error) return check + + const accountId = checkedToAccountId(accountIdRaw) + if (accountId instanceof Error) return accountId + + const quizzesConfig = getQuizzesConfig() + + // TODO: quizQuestionId checkedFor + const quizId = quizQuestionIdString as QuizQuestionId + + const amount = QuizzesValue[quizId] + if (!amount) return new InvalidQuizQuestionIdError() + + const funderWalletId = await getFunderWalletId() + const funderWallet = await WalletsRepository().findById(funderWalletId) + if (funderWallet instanceof Error) return funderWallet + const funderAccount = await AccountsRepository().findById(funderWallet.accountId) + if (funderAccount instanceof Error) return funderAccount + + 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 validatedPhoneMetadata = PhoneMetadataAuthorizer( + quizzesConfig.phoneMetadataValidationSettings, + ).authorize(user.phoneMetadata) + + if (validatedPhoneMetadata instanceof Error) { + return new InvalidPhoneForQuizError(validatedPhoneMetadata.name) + } + + const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id) + if (accountIP instanceof Error) return accountIP + + const validatedIPMetadata = IPMetadataAuthorizer( + quizzesConfig.ipMetadataValidationSettings, + ).authorize(accountIP.metadata) + if (validatedIPMetadata instanceof Error) { + if (validatedIPMetadata instanceof MissingIPMetadataError) + return new InvalidIpMetadataError(validatedIPMetadata) + + return validatedIPMetadata + } + + const quizzesBefore = await listQuizzesByAccountId(accountId) + if (quizzesBefore instanceof Error) return quizzesBefore + + const quiz = quizzesBefore.find((quiz) => quiz.id === quizId) + if (quiz === undefined) return new InvalidQuizQuestionIdError() + + if (quiz.notBefore && quiz.notBefore > new Date()) return new QuizClaimedTooEarlyError() + + const recipientWallets = await WalletsRepository().listByAccountId(accountId) + if (recipientWallets instanceof Error) return recipientWallets + + const recipientBtcWallet = recipientWallets.find( + (wallet) => wallet.currency === WalletCurrency.Btc, + ) + if (recipientBtcWallet === undefined) return new NoBtcWalletExistsForAccountError() + const recipientWalletId = recipientBtcWallet.id + + const shouldGiveSats = await QuizRepository().add({ quizId, accountId }) + if (shouldGiveSats instanceof Error) return shouldGiveSats + + const funderBalance = await getBalanceForWallet({ walletId: funderWalletId }) + if (funderBalance instanceof Error) return funderBalance + + const sendCheck = FunderBalanceChecker().check({ + balance: funderBalance as Satoshis, + amountToSend: amount, + }) + if (sendCheck instanceof Error) return sendCheck + + const payment = await intraledgerPaymentSendWalletIdForBtcWallet({ + senderWalletId: funderWalletId, + recipientWalletId, + amount, + memo: quizId, + senderAccount: funderAccount, + }) + if (payment instanceof Error) return payment + + const quizzesAfter = await listQuizzesByAccountId(accountId) + if (quizzesAfter instanceof Error) return quizzesAfter + + return quizzesAfter +} + const FunderBalanceChecker = () => { const check = ({ balance, diff --git a/core/api/src/app/quiz/get.ts b/core/api/src/app/quiz/get.ts deleted file mode 100644 index 356e0e0193..0000000000 --- a/core/api/src/app/quiz/get.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { QuizzesValue } from "@/domain/earn/config" -import { QuizRepository } from "@/services/mongoose" - -export const getQuizzesByAccountId = async (accountId: AccountId) => { - const quizzes = await QuizRepository().fetchAll(accountId) - if (quizzes instanceof Error) return quizzes - - const solvedQuizId = quizzes.map((quiz) => quiz.quizId) - - const result = Object.entries(QuizzesValue).map(([id, amount]) => ({ - id: id as QuizQuestionId, - amount, - completed: solvedQuizId.indexOf(id) > -1, - })) - - return result -} diff --git a/core/api/src/app/quiz/index.ts b/core/api/src/app/quiz/index.ts index aa853ba7b7..05f7096010 100644 --- a/core/api/src/app/quiz/index.ts +++ b/core/api/src/app/quiz/index.ts @@ -1,2 +1,2 @@ -export * from "./add" -export * from "./get" +export * from "./claim" +export * from "./list" diff --git a/core/api/src/app/quiz/list.ts b/core/api/src/app/quiz/list.ts new file mode 100644 index 0000000000..6c11df0b4c --- /dev/null +++ b/core/api/src/app/quiz/list.ts @@ -0,0 +1,9 @@ +import { fillQuizInformation } from "@/domain/quiz" +import { QuizRepository } from "@/services/mongoose" + +export const listQuizzesByAccountId = async (accountId: AccountId) => { + const quizzes = await QuizRepository().fetchAll(accountId) + if (quizzes instanceof Error) return quizzes + + return fillQuizInformation(quizzes).quizzes +} diff --git a/core/api/src/config/index.ts b/core/api/src/config/index.ts index d2256b1c20..b331e05358 100644 --- a/core/api/src/config/index.ts +++ b/core/api/src/config/index.ts @@ -13,7 +13,7 @@ export * from "./schema" import { ConfigError } from "./error" import { toDays } from "@/domain/primitives" -import { QuizzesValue } from "@/domain/earn" +import { QuizzesValue } from "@/domain/quiz" export const MS_PER_SEC = 1000 as MilliSeconds export const MS_PER_5_MINS = (60 * 5 * MS_PER_SEC) as MilliSeconds diff --git a/core/api/src/config/index.types.d.ts b/core/api/src/config/index.types.d.ts index adac4c3952..5d6a8c69ad 100644 --- a/core/api/src/config/index.types.d.ts +++ b/core/api/src/config/index.types.d.ts @@ -1,126 +1 @@ -type QuizQuestionId = - | "walletDownloaded" - | "walletActivated" - | "whatIsBitcoin" - | "sat" - | "whereBitcoinExist" - | "whoControlsBitcoin" - | "copyBitcoin" - | "moneySocialAgreement" - | "coincidenceOfWants" - | "moneyEvolution" - | "whyStonesShellGold" - | "moneyIsImportant" - | "moneyImportantGovernement" - | "WhatIsFiat" - | "whyCareAboutFiatMoney" - | "GovernementCanPrintMoney" - | "FiatLosesValueOverTime" - | "OtherIssues" - | "LimitedSupply" - | "Decentralized" - | "NoCounterfeitMoney" - | "HighlyDivisible" - | "securePartOne" - | "securePartTwo" - | "originsOfMoney" - | "primitiveMoney" - | "anticipatingDemand" - | "nashEquilibrium" - | "singleStoreOfValue" - | "whatIsGoodSOV" - | "durability" - | "portability" - | "fungibility" - | "verifiability" - | "divisibility" - | "scarce" - | "establishedHistory" - | "censorshipResistance" - | "evolutionMoney" - | "collectible" - | "storeOfValue" - | "mediumOfExchange" - | "unitOfAccount" - | "partlyMonetized" - | "monetizationStage" - | "notFromGovernment" - | "primaryFunction" - | "monetaryMetals" - | "stockToFlow" - | "hardMoney" - | "convergingOnGold" - | "originsOfPaperMoney" - | "fractionalReserve" - | "bankRun" - | "modernCentralBanking" - | "goldBacked" - | "brettonWoods" - | "globalReserve" - | "nixonShock" - | "fiatEra" - | "digitalFiat" - | "plasticCredit" - | "doubleSpendProblem" - | "satoshisBreakthrough" - | "nativelyDigital" - | "CBDCs" - | "rootProblem" - | "bitcoinCreator" - | "fiatRequiresTrust" - | "moneyPrinting" - | "genesisBlock" - | "cypherpunks" - | "peer2Peer" - | "blockchain" - | "privateKey" - | "publicKey" - | "mining" - | "proofOfWork" - | "difficultyAdjustment" - | "halving" - | "bitcoinDrawbacks" - | "blocksizeWars" - | "lightningNetwork" - | "instantPayments" - | "micropayments" - | "scalability" - | "paymentChannels" - | "routing" - | "itsaBubble" - | "itstooVolatile" - | "itsnotBacked" - | "willbecomeObsolete" - | "toomuchEnergy" - | "strandedEnergy" - | "internetDependent" - | "forcrimeOnly" - | "ponziScheme" - | "bitcoinisTooSlow" - | "supplyLimit" - | "governmentBan" - | "concentratedOwnership" - | "centralizedMining" - | "tooExpensive" - | "prohibitivelyHigh" - | "willBeHoarded" - | "canBeDuplicated" - | "scarcity" - | "monetaryPremium" - | "greshamsLaw" - | "thiersLaw" - | "cantillonEffect" - | "schellingPoint" - | "opportunityCost" - | "timePreference" - | "impossibleTrinity" - | "jevonsParadox" - | "powerLaws" - | "winnerTakeAll" - | "unitBias" - | "veblenGood" - | "malinvestment" - | "asymmetricPayoff" - | "ansoffMatrix" - type SkipFeeProbeConfig = { pubkey: Pubkey[]; chanId: ChanId[] } diff --git a/core/api/src/debug/print-sections-for-quizzes.ts b/core/api/src/debug/print-sections-for-quizzes.ts new file mode 100644 index 0000000000..98237498b1 --- /dev/null +++ b/core/api/src/debug/print-sections-for-quizzes.ts @@ -0,0 +1,20 @@ +// this script generates the content saved to ./sections.ts +// it assumes: +// - galoy-mobile is the source of truth for the different sections +// - galoy-mobile is in the same root as galoy repo +// pnpm tsx src/debug/print-sections-for-quizzes.ts +/* eslint @typescript-eslint/ban-ts-comment: "off" */ +// @ts-nocheck + +import en from "../../../../../galoy-mobile/app/i18n/en/index" + +const earnSection = en.EarnScreen.earnSections + +const transformedJson = Object.keys(earnSection).map((section) => { + return { + section: section, + quiz: Object.keys(earnSection[section].questions), + } +}) + +console.log(JSON.stringify(transformedJson, null, 2)) diff --git a/core/api/src/domain/accounts/index.types.d.ts b/core/api/src/domain/accounts/index.types.d.ts index b9d8747b90..9a2b106378 100644 --- a/core/api/src/domain/accounts/index.types.d.ts +++ b/core/api/src/domain/accounts/index.types.d.ts @@ -114,24 +114,6 @@ type Account = { role?: string } -// deprecated -type QuizQuestion = { - readonly id: QuizQuestionId - readonly earnAmount: Satoshis -} - -// deprecated -type UserQuizQuestion = { - readonly question: QuizQuestion - completed: boolean -} - -type Quiz = { - readonly id: QuizQuestionId - readonly amount: Satoshis - readonly completed: boolean -} - type BusinessMapTitle = string & { readonly brand: unique symbol } type Coordinates = { longitude: number diff --git a/core/api/src/domain/earn/index.ts b/core/api/src/domain/earn/index.ts deleted file mode 100644 index 6502e92cf5..0000000000 --- a/core/api/src/domain/earn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./config" diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index 4266c056a9..b3cd438ee7 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -111,6 +111,7 @@ export class NoContactForUsernameError extends ValidationError {} export class NoWalletExistsForUserError extends ValidationError {} export class NoBtcWalletExistsForAccountError extends ValidationError {} export class InvalidQuizQuestionIdError extends ValidationError {} +export class QuizClaimedTooEarlyError extends ValidationError {} export class MissingIPMetadataError extends ValidationError {} export class InvalidIpMetadataError extends ValidationError { diff --git a/core/api/src/domain/earn/config.ts b/core/api/src/domain/quiz/config.ts similarity index 98% rename from core/api/src/domain/earn/config.ts rename to core/api/src/domain/quiz/config.ts index 2f07faace6..e568e72059 100644 --- a/core/api/src/domain/earn/config.ts +++ b/core/api/src/domain/quiz/config.ts @@ -1,6 +1,6 @@ +export const milliSecondsBetweenSections = 60 * 60 * 12 * 1000 + export const QuizzesValue: Record = { - walletDownloaded: 1 as Satoshis, - walletActivated: 1 as Satoshis, whatIsBitcoin: 1 as Satoshis, sat: 1 as Satoshis, whereBitcoinExist: 1 as Satoshis, diff --git a/core/api/src/domain/quiz/index.ts b/core/api/src/domain/quiz/index.ts new file mode 100644 index 0000000000..a50fd44955 --- /dev/null +++ b/core/api/src/domain/quiz/index.ts @@ -0,0 +1,77 @@ +export * from "./config" + +import { QuizzesValue, milliSecondsBetweenSections } from "./config" +import { QuizzesSectionsConfig } from "./sections" + +export interface QuizCompleted { + quizId: QuizQuestionId + createdAt: Date +} + +export const QuizzesSections = QuizzesSectionsConfig.map((value, index) => ({ + order: index, + ...value, +})) + +type FillQuizInformationResult = { + currentSection: number + quizzes: { + id: QuizQuestionId + amount: Satoshis + completed: boolean + section: number + notBefore: Date | undefined + }[] +} + +const lastSectionCompleted = (quizzesCompleted: QuizCompleted[]): number => { + const quizzesCompletedIds = quizzesCompleted.map((quiz) => quiz.quizId) + const lastQuizCompleted = quizzesCompletedIds[quizzesCompletedIds.length - 1] + + // which section are we? + const index = QuizzesSections.findIndex((section) => + section.quiz.includes(lastQuizCompleted), + ) + + // if we completed the last quiz of the current section, we move to the next + const section = QuizzesSections[index]?.quiz + if (section && section[section.length - 1] === lastQuizCompleted) return index + 1 + + return index === -1 ? 0 : index +} + +export const fillQuizInformation = ( + quizzesCompleted: QuizCompleted[], +): FillQuizInformationResult => { + const currentSection = lastSectionCompleted(quizzesCompleted) + + const quizzes = Object.entries(QuizzesValue).map(([id, amount]) => { + const quizCompleted = quizzesCompleted.find((quiz) => quiz.quizId === id) + const section = + QuizzesSections.find((section) => section.quiz.includes(id as QuizQuestionId)) + ?.order ?? NaN + + const completed = !!quizCompleted + + let notBefore: Date | undefined = undefined + if (section !== 0 && !completed) { + const lastQuizCreatedAt = + quizzesCompleted[quizzesCompleted.length - 1]?.createdAt ?? new Date() + + notBefore = new Date(lastQuizCreatedAt.getTime() + milliSecondsBetweenSections) + } + + return { + id: id as QuizQuestionId, + amount, + completed, + section, + notBefore, + } + }) + + return { + currentSection, + quizzes, + } +} diff --git a/core/api/src/domain/quiz/index.types.d.ts b/core/api/src/domain/quiz/index.types.d.ts new file mode 100644 index 0000000000..65682ba580 --- /dev/null +++ b/core/api/src/domain/quiz/index.types.d.ts @@ -0,0 +1,2 @@ +type QuizzesSectionsConfig = typeof import("./sections").QuizzesSectionsConfig +type QuizQuestionId = QuizzesSectionsConfig[number]["quiz"][number] diff --git a/core/api/src/domain/quiz/sections.ts b/core/api/src/domain/quiz/sections.ts new file mode 100644 index 0000000000..713f990a3c --- /dev/null +++ b/core/api/src/domain/quiz/sections.ts @@ -0,0 +1,212 @@ +export const QuizzesSectionsConfig = [ + { + section: "bitcoinWhatIsIt", + quiz: [ + "whatIsBitcoin", + "sat", + "whereBitcoinExist", + "whoControlsBitcoin", + "copyBitcoin", + ], + }, + { + section: "WhatIsMoney", + quiz: [ + "moneySocialAgreement", + "coincidenceOfWants", + "moneyEvolution", + "whyStonesShellGold", + "moneyIsImportant", + "moneyImportantGovernement", + ], + }, + { + section: "HowDoesMoneyWork", + quiz: [ + "WhatIsFiat", + "whyCareAboutFiatMoney", + "GovernementCanPrintMoney", + "FiatLosesValueOverTime", + "OtherIssues", + ], + }, + { + section: "BitcoinWhySpecial", + quiz: [ + "LimitedSupply", + "Decentralized", + "NoCounterfeitMoney", + "HighlyDivisible", + "securePartOne", + "securePartTwo", + ], + }, + { + section: "TheOriginsOfMoney", + quiz: [ + "originsOfMoney", + "primitiveMoney", + "anticipatingDemand", + "nashEquilibrium", + "singleStoreOfValue", + ], + }, + { + section: "AttributesOfAGoodStoreOfValue", + quiz: [ + "whatIsGoodSOV", + "durability", + "portability", + "fungibility", + "verifiability", + "divisibility", + "scarce", + "establishedHistory", + "censorshipResistance", + ], + }, + { + section: "TheEvolutionOfMoneyI", + quiz: [ + "evolutionMoney", + "collectible", + "storeOfValue", + "mediumOfExchange", + "unitOfAccount", + "partlyMonetized", + "monetizationStage", + ], + }, + { + section: "TheEvolutionOfMoneyII", + quiz: [ + "notFromGovernment", + "primaryFunction", + "monetaryMetals", + "stockToFlow", + "hardMoney", + ], + }, + { + section: "TheEvolutionOfMoneyIII", + quiz: [ + "convergingOnGold", + "originsOfPaperMoney", + "fractionalReserve", + "bankRun", + "modernCentralBanking", + "goldBacked", + "brettonWoods", + "globalReserve", + ], + }, + { + section: "TheEvolutionOfMoneyIV", + quiz: [ + "nixonShock", + "fiatEra", + "digitalFiat", + "plasticCredit", + "doubleSpendProblem", + "satoshisBreakthrough", + "nativelyDigital", + "CBDCs", + ], + }, + { + section: "BitcoinWhyWasItCreated", + quiz: [ + "rootProblem", + "bitcoinCreator", + "fiatRequiresTrust", + "moneyPrinting", + "genesisBlock", + "cypherpunks", + ], + }, + { + section: "BitcoinHowDoesItWork", + quiz: [ + "peer2Peer", + "blockchain", + "privateKey", + "publicKey", + "mining", + "proofOfWork", + "difficultyAdjustment", + "halving", + ], + }, + { + section: "LightningWhatDoesItSolve", + quiz: [ + "bitcoinDrawbacks", + "blocksizeWars", + "lightningNetwork", + "instantPayments", + "micropayments", + "scalability", + "paymentChannels", + "routing", + ], + }, + { + section: "BitcoinCriticismsFallaciesI", + quiz: [ + "itsaBubble", + "itstooVolatile", + "itsnotBacked", + "willbecomeObsolete", + "toomuchEnergy", + "strandedEnergy", + ], + }, + { + section: "BitcoinCriticismsFallaciesII", + quiz: [ + "internetDependent", + "forcrimeOnly", + "ponziScheme", + "bitcoinisTooSlow", + "supplyLimit", + "governmentBan", + ], + }, + { + section: "BitcoinCriticismsFallaciesIII", + quiz: [ + "concentratedOwnership", + "centralizedMining", + "tooExpensive", + "prohibitivelyHigh", + "willBeHoarded", + "canBeDuplicated", + ], + }, + { + section: "BitcoinAndEconomicsI", + quiz: [ + "scarcity", + "monetaryPremium", + "greshamsLaw", + "thiersLaw", + "cantillonEffect", + "schellingPoint", + ], + }, + { + section: "BitcoinAndEconomicsII", + quiz: [ + "opportunityCost", + "timePreference", + "impossibleTrinity", + "jevonsParadox", + "powerLaws", + "winnerTakeAll", + ], + }, + { + section: "BitcoinAndEconomicsIII", + quiz: ["unitBias", "veblenGood", "malinvestment", "asymmetricPayoff", "ansoffMatrix"], + }, +] as const diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 7b16e5abae..05e0ce7d7d 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -35,6 +35,7 @@ import { UnauthorizedIPMetadataCountryError, LikelyBadCoreError, LnurlRequestInvoiceError, + QuizClaimedTooEarlyError, } from "@/graphql/error" import { baseLogger } from "@/services/logger" @@ -485,6 +486,10 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = error.message return new LikelyBadCoreError({ message, logger: baseLogger }) + case "QuizClaimedTooEarlyError": + message = error.message + return new QuizClaimedTooEarlyError({ message, logger: baseLogger }) + // ---------- // Unhandled below here // ---------- diff --git a/core/api/src/graphql/error.ts b/core/api/src/graphql/error.ts index a56c24bf21..9a9a1e46fb 100644 --- a/core/api/src/graphql/error.ts +++ b/core/api/src/graphql/error.ts @@ -77,6 +77,17 @@ export class LikelyBadCoreError extends CustomGraphQLError { } } +export class QuizClaimedTooEarlyError extends CustomGraphQLError { + constructor(errData: CustomGraphQLErrorData) { + super({ + ...errData, + code: "QUIZ_CLAIMED_TOO_EARLY", + forwardToClient: true, + message: "Quiz can't be claimed yet", + }) + } +} + export class ValidationInternalError extends CustomGraphQLError { constructor(errData: CustomGraphQLErrorData) { super({ code: "INVALID_INPUT", forwardToClient: true, ...errData }) diff --git a/core/api/src/graphql/public/mutations.ts b/core/api/src/graphql/public/mutations.ts index 1ddd800780..d5a4e756b6 100644 --- a/core/api/src/graphql/public/mutations.ts +++ b/core/api/src/graphql/public/mutations.ts @@ -62,6 +62,7 @@ import UserUpdateLanguageMutation from "@/graphql/public/root/mutation/user-upda import UserUpdateUsernameMutation from "@/graphql/public/root/mutation/user-update-username" import CaptchaCreateChallengeMutation from "@/graphql/public/root/mutation/captcha-create-challenge" import CaptchaRequestAuthCodeMutation from "@/graphql/public/root/mutation/captcha-request-auth-code" +import QuizClaimMutation from "@/graphql/public/root/mutation/quiz-claim" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } export const mutationFields = { @@ -93,6 +94,7 @@ export const mutationFields = { userLogout: UserLogoutMutation, quizCompleted: QuizCompletedMutation, + quizClaim: QuizClaimMutation, deviceNotificationTokenCreate: DeviceNotificationTokenCreateMutation, userUpdateLanguage: UserUpdateLanguageMutation, diff --git a/core/api/src/graphql/public/root/mutation/quiz-claim.ts b/core/api/src/graphql/public/root/mutation/quiz-claim.ts new file mode 100644 index 0000000000..d71d420084 --- /dev/null +++ b/core/api/src/graphql/public/root/mutation/quiz-claim.ts @@ -0,0 +1,45 @@ +import { Quiz } from "@/app" +import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" +import { GT } from "@/graphql/index" + +import QuizClaim from "@/graphql/public/types/payload/quiz-claim" + +const QuizClaimInput = GT.Input({ + name: "QuizClaimInput", + fields: () => ({ + id: { type: GT.NonNull(GT.ID) }, + }), +}) + +const QuizClaimMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { input: { id: string } } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(QuizClaim), + args: { + input: { type: GT.NonNull(QuizClaimInput) }, + }, + resolve: async (_, args, { domainAccount, ip }) => { + const { id } = args.input + + const quizzes = await Quiz.claimQuiz({ + quizQuestionId: id, + accountId: domainAccount.id, + ip, + }) + if (quizzes instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(quizzes)], quizzes: [] } + } + + return { + errors: [], + quizzes, + } + }, +}) + +export default QuizClaimMutation 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 0c932b6905..faa2122860 100644 --- a/core/api/src/graphql/public/root/mutation/quiz-completed.ts +++ b/core/api/src/graphql/public/root/mutation/quiz-completed.ts @@ -19,6 +19,7 @@ const QuizCompletedMutation = GT.Field< extensions: { complexity: 120, }, + deprecationReason: "Use quizClaim instead", type: GT.NonNull(QuizCompleted), args: { input: { type: GT.NonNull(QuizCompletedInput) }, @@ -26,22 +27,18 @@ const QuizCompletedMutation = GT.Field< resolve: async (_, args, { domainAccount, ip }) => { const { id } = args.input - const question = await Quiz.completeQuiz({ + const quizzes = await Quiz.claimQuizLegacy({ quizQuestionId: id, accountId: domainAccount.id, ip, }) - if (question instanceof Error) { - return { errors: [mapAndParseErrorForGqlResponse(question)] } + if (quizzes instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(quizzes)] } } return { errors: [], - quiz: { - id: question.id, - amount: question.earnAmount, - completed: true, - }, + quiz: quizzes.find((q) => q.id === id), } }, }) diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index 5b750ef2b4..1f1c4b5723 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -904,7 +904,8 @@ type Mutation { onChainPaymentSendAll(input: OnChainPaymentSendAllInput!): PaymentSendPayload! onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! - quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! + quizClaim(input: QuizClaimInput!): QuizClaimPayload! + quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @deprecated(reason: "Use quizClaim instead") userContactUpdateAlias(input: UserContactUpdateAliasInput!): UserContactUpdateAliasPayload! @deprecated(reason: "will be moved to AccountContact") userEmailDelete: UserEmailDeletePayload! userEmailRegistrationInitiate(input: UserEmailRegistrationInitiateInput!): UserEmailRegistrationInitiatePayload! @@ -1181,6 +1182,16 @@ type Quiz { amount: SatAmount! completed: Boolean! id: ID! + notBefore: Timestamp +} + +input QuizClaimInput { + id: ID! +} + +type QuizClaimPayload { + errors: [Error!]! + quizzes: [Quiz!]! } input QuizCompletedInput { 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 8c194a8fe9..55c057f48c 100644 --- a/core/api/src/graphql/public/types/object/consumer-account.ts +++ b/core/api/src/graphql/public/types/object/consumer-account.ts @@ -14,7 +14,7 @@ import { NotificationSettings } from "./notification-settings" import PublicWallet from "./public-wallet" -import { Accounts, Prices, Wallets } from "@/app" +import { Accounts, Prices, Wallets, Quiz as QuizApp } from "@/app" import { majorToMinorUnit, @@ -34,7 +34,6 @@ import DisplayCurrency from "@/graphql/shared/types/scalar/display-currency" import { listEndpoints } from "@/app/callback" import { IInvoiceConnection } from "@/graphql/shared/types/abstract/invoice" -import { getQuizzesByAccountId } from "@/app/quiz" const ConsumerAccount = GT.Object({ name: "ConsumerAccount", @@ -165,12 +164,13 @@ const ConsumerAccount = GT.Object({ resolve: (source) => source, }, + // TODO: should be quizzes quiz: { type: GT.NonNullList(Quiz), description: "List the quiz questions of the consumer account", resolve: async (source) => { const accountId = source.id - const result = await getQuizzesByAccountId(accountId) + const result = await QuizApp.listQuizzesByAccountId(accountId) if (result instanceof Error) { throw mapError(result) diff --git a/core/api/src/graphql/public/types/object/quiz-question.ts b/core/api/src/graphql/public/types/object/quiz-question.ts deleted file mode 100644 index cb9cd6363f..0000000000 --- a/core/api/src/graphql/public/types/object/quiz-question.ts +++ /dev/null @@ -1,17 +0,0 @@ -import SatAmount from "../../../shared/types/scalar/sat-amount" - -import { GT } from "@/graphql/index" - -// deprecated // TODO: remove -const QuizQuestion = GT.Object({ - name: "QuizQuestion", - fields: () => ({ - id: { type: GT.NonNullID }, - earnAmount: { - type: GT.NonNull(SatAmount), - description: "The earn reward in Satoshis for the quiz question", - }, - }), -}) - -export default QuizQuestion diff --git a/core/api/src/graphql/public/types/object/quiz.ts b/core/api/src/graphql/public/types/object/quiz.ts index 699fbb1b5b..69380c076f 100644 --- a/core/api/src/graphql/public/types/object/quiz.ts +++ b/core/api/src/graphql/public/types/object/quiz.ts @@ -1,5 +1,7 @@ import SatAmount from "../../../shared/types/scalar/sat-amount" +import Timestamp from "@/graphql/shared/types/scalar/timestamp" + import { GT } from "@/graphql/index" const Quiz = GT.Object({ @@ -11,6 +13,7 @@ const Quiz = GT.Object({ description: "The reward in Satoshis for the quiz question", }, completed: { type: GT.NonNull(GT.Boolean) }, + notBefore: { type: Timestamp }, }), }) diff --git a/core/api/src/graphql/public/types/object/user-quiz-question.ts b/core/api/src/graphql/public/types/object/user-quiz-question.ts deleted file mode 100644 index 612b429962..0000000000 --- a/core/api/src/graphql/public/types/object/user-quiz-question.ts +++ /dev/null @@ -1,14 +0,0 @@ -import QuizQuestion from "./quiz-question" - -import { GT } from "@/graphql/index" - -// deprecated // TODO: remove -const UserQuizQuestion = GT.Object({ - name: "UserQuizQuestion", - fields: () => ({ - question: { type: GT.NonNull(QuizQuestion) }, - completed: { type: GT.NonNull(GT.Boolean) }, - }), -}) - -export default UserQuizQuestion diff --git a/core/api/src/graphql/public/types/payload/account-delete.ts b/core/api/src/graphql/public/types/payload/account-delete.ts index 51c3693a87..2042d39998 100644 --- a/core/api/src/graphql/public/types/payload/account-delete.ts +++ b/core/api/src/graphql/public/types/payload/account-delete.ts @@ -1,4 +1,4 @@ -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" diff --git a/core/api/src/graphql/public/types/payload/account-update-default-wallet-id.ts b/core/api/src/graphql/public/types/payload/account-update-default-wallet-id.ts index 1480fac6e1..e2b0dce652 100644 --- a/core/api/src/graphql/public/types/payload/account-update-default-wallet-id.ts +++ b/core/api/src/graphql/public/types/payload/account-update-default-wallet-id.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import ConsumerAccount from "../object/consumer-account" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const AccountUpdateDefaultWalletIdPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/account-update-display-currency.ts b/core/api/src/graphql/public/types/payload/account-update-display-currency.ts index e36229be33..3a7075d02e 100644 --- a/core/api/src/graphql/public/types/payload/account-update-display-currency.ts +++ b/core/api/src/graphql/public/types/payload/account-update-display-currency.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import ConsumerAccount from "../object/consumer-account" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const AccountUpdateDisplayCurrencyPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/account-update-notification-settings.ts b/core/api/src/graphql/public/types/payload/account-update-notification-settings.ts index 8c160b6397..57f7d78771 100644 --- a/core/api/src/graphql/public/types/payload/account-update-notification-settings.ts +++ b/core/api/src/graphql/public/types/payload/account-update-notification-settings.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import ConsumerAccount from "../object/consumer-account" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const AccountUpdateNotificationSettingsPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/callback-endpoint-add.ts b/core/api/src/graphql/public/types/payload/callback-endpoint-add.ts index 1e287a1b87..a71bf22f65 100644 --- a/core/api/src/graphql/public/types/payload/callback-endpoint-add.ts +++ b/core/api/src/graphql/public/types/payload/callback-endpoint-add.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import EndpointId from "../scalar/endpoint-id" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const CallbackEndpointAddPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/ln-invoice-payment-status.ts b/core/api/src/graphql/public/types/payload/ln-invoice-payment-status.ts index 2ff383f4e0..60aefdb5eb 100644 --- a/core/api/src/graphql/public/types/payload/ln-invoice-payment-status.ts +++ b/core/api/src/graphql/public/types/payload/ln-invoice-payment-status.ts @@ -1,7 +1,6 @@ -import IError from "../../../shared/types/abstract/error" - import InvoicePaymentStatus from "../../../shared/types/scalar/invoice-payment-status" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const LnInvoicePaymentStatusPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/ln-invoice.ts b/core/api/src/graphql/public/types/payload/ln-invoice.ts index 1cce9af689..6e50a5e9d5 100644 --- a/core/api/src/graphql/public/types/payload/ln-invoice.ts +++ b/core/api/src/graphql/public/types/payload/ln-invoice.ts @@ -1,7 +1,6 @@ -import IError from "../../../shared/types/abstract/error" - import LnInvoice from "../../../shared/types/object/ln-invoice" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const LnInvoicePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/ln-noamount-invoice.ts b/core/api/src/graphql/public/types/payload/ln-noamount-invoice.ts index ee8d3671bc..f94e3c7594 100644 --- a/core/api/src/graphql/public/types/payload/ln-noamount-invoice.ts +++ b/core/api/src/graphql/public/types/payload/ln-noamount-invoice.ts @@ -1,7 +1,6 @@ -import IError from "../../../shared/types/abstract/error" - import LnNoAmountInvoice from "../../../shared/types/object/ln-noamount-invoice" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const LnNoAmountInvoicePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/on-chain-address.ts b/core/api/src/graphql/public/types/payload/on-chain-address.ts index 2502c9c7eb..6331d5ebd2 100644 --- a/core/api/src/graphql/public/types/payload/on-chain-address.ts +++ b/core/api/src/graphql/public/types/payload/on-chain-address.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import OnChainAddress from "../../../shared/types/scalar/on-chain-address" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const OnChainAddressPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/price.ts b/core/api/src/graphql/public/types/payload/price.ts index 6993022665..a351ef695d 100644 --- a/core/api/src/graphql/public/types/payload/price.ts +++ b/core/api/src/graphql/public/types/payload/price.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import Price from "../object/price" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const PricePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/quiz-claim.ts b/core/api/src/graphql/public/types/payload/quiz-claim.ts new file mode 100644 index 0000000000..12ef852395 --- /dev/null +++ b/core/api/src/graphql/public/types/payload/quiz-claim.ts @@ -0,0 +1,18 @@ +import IError from "../../../shared/types/abstract/error" +import Quiz from "../object/quiz" + +import { GT } from "@/graphql/index" + +const QuizClaimPayload = GT.Object({ + name: "QuizClaimPayload", + fields: () => ({ + errors: { + type: GT.NonNullList(IError), + }, + quizzes: { + type: GT.NonNullList(Quiz), + }, + }), +}) + +export default QuizClaimPayload 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..909d3d3ecc 100644 --- a/core/api/src/graphql/public/types/payload/quiz-completed.ts +++ b/core/api/src/graphql/public/types/payload/quiz-completed.ts @@ -1,8 +1,9 @@ -import IError from "../../../shared/types/abstract/error" import Quiz from "../object/quiz" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" +// deprecated const QuizCompletedPayload = GT.Object({ name: "QuizCompletedPayload", fields: () => ({ diff --git a/core/api/src/graphql/public/types/payload/realtime-price.ts b/core/api/src/graphql/public/types/payload/realtime-price.ts index 18e3b0a322..3e7328387e 100644 --- a/core/api/src/graphql/public/types/payload/realtime-price.ts +++ b/core/api/src/graphql/public/types/payload/realtime-price.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import RealtimePrice from "../object/realtime-price" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const RealtimePricePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/upgrade-payload.ts b/core/api/src/graphql/public/types/payload/upgrade-payload.ts index 3ae646157c..ed15665868 100644 --- a/core/api/src/graphql/public/types/payload/upgrade-payload.ts +++ b/core/api/src/graphql/public/types/payload/upgrade-payload.ts @@ -1,4 +1,4 @@ -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" diff --git a/core/api/src/graphql/public/types/payload/user-contact-update-alias.ts b/core/api/src/graphql/public/types/payload/user-contact-update-alias.ts index b85b43f421..9681037fa1 100644 --- a/core/api/src/graphql/public/types/payload/user-contact-update-alias.ts +++ b/core/api/src/graphql/public/types/payload/user-contact-update-alias.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import AccountContact from "../object/account-contact" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const AccountContactUpdateAliasPayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-email-delete.ts b/core/api/src/graphql/public/types/payload/user-email-delete.ts index 665d09fc99..9039ea403c 100644 --- a/core/api/src/graphql/public/types/payload/user-email-delete.ts +++ b/core/api/src/graphql/public/types/payload/user-email-delete.ts @@ -1,6 +1,6 @@ import GraphQLUser from "../object/user" -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserEmailDeletePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-email-registration-initiate.ts b/core/api/src/graphql/public/types/payload/user-email-registration-initiate.ts index 3a724f14da..37461afea0 100644 --- a/core/api/src/graphql/public/types/payload/user-email-registration-initiate.ts +++ b/core/api/src/graphql/public/types/payload/user-email-registration-initiate.ts @@ -1,7 +1,7 @@ -import IError from "../../../shared/types/abstract/error" import EmailRegistrationId from "../scalar/email-verify-id" import GraphQLUser from "../object/user" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserEmailRegistrationInitiatePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-email-registration-validate.ts b/core/api/src/graphql/public/types/payload/user-email-registration-validate.ts index 176ff6a2d5..fab552b617 100644 --- a/core/api/src/graphql/public/types/payload/user-email-registration-validate.ts +++ b/core/api/src/graphql/public/types/payload/user-email-registration-validate.ts @@ -1,6 +1,6 @@ import GraphQLUser from "../object/user" -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserEmailRegistrationValidatePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-phone-delete.ts b/core/api/src/graphql/public/types/payload/user-phone-delete.ts index b4d5d7883c..178320c297 100644 --- a/core/api/src/graphql/public/types/payload/user-phone-delete.ts +++ b/core/api/src/graphql/public/types/payload/user-phone-delete.ts @@ -1,6 +1,6 @@ import GraphQLUser from "../object/user" -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserPhoneDeletePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-phone-registration-validate.ts b/core/api/src/graphql/public/types/payload/user-phone-registration-validate.ts index 095e679a8b..d98122494d 100644 --- a/core/api/src/graphql/public/types/payload/user-phone-registration-validate.ts +++ b/core/api/src/graphql/public/types/payload/user-phone-registration-validate.ts @@ -1,6 +1,6 @@ import GraphQLUser from "../object/user" -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserPhoneRegistrationValidatePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-totp-delete.ts b/core/api/src/graphql/public/types/payload/user-totp-delete.ts index 0446f4760b..2876901888 100644 --- a/core/api/src/graphql/public/types/payload/user-totp-delete.ts +++ b/core/api/src/graphql/public/types/payload/user-totp-delete.ts @@ -1,6 +1,6 @@ import GraphQLUser from "../object/user" -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserTotpDeletePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-totp-registration-initiate.ts b/core/api/src/graphql/public/types/payload/user-totp-registration-initiate.ts index 6423fe25f4..ac333d2285 100644 --- a/core/api/src/graphql/public/types/payload/user-totp-registration-initiate.ts +++ b/core/api/src/graphql/public/types/payload/user-totp-registration-initiate.ts @@ -1,7 +1,7 @@ -import IError from "../../../shared/types/abstract/error" import TotpRegistrationId from "../scalar/totp-verify-id" import TotpSecret from "../scalar/totp-secret" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserTotpRegistrationInitiatePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-totp-registration-validate.ts b/core/api/src/graphql/public/types/payload/user-totp-registration-validate.ts index 83e7a809f9..5da7c05bb3 100644 --- a/core/api/src/graphql/public/types/payload/user-totp-registration-validate.ts +++ b/core/api/src/graphql/public/types/payload/user-totp-registration-validate.ts @@ -1,6 +1,6 @@ -import IError from "../../../shared/types/abstract/error" import GraphQLUser from "../object/user" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserTotpRegistrationValidatePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-update-language.ts b/core/api/src/graphql/public/types/payload/user-update-language.ts index 5bc600fe01..16e54526f0 100644 --- a/core/api/src/graphql/public/types/payload/user-update-language.ts +++ b/core/api/src/graphql/public/types/payload/user-update-language.ts @@ -1,6 +1,6 @@ import GraphQLUser from "../object/user" -import IError from "../../../shared/types/abstract/error" +import IError from "@/graphql/shared/types/abstract/error" import { GT } from "@/graphql/index" const UserUpdateLanguagePayload = GT.Object({ diff --git a/core/api/src/graphql/public/types/payload/user-update-username.ts b/core/api/src/graphql/public/types/payload/user-update-username.ts index 89892eb36d..409be963d7 100644 --- a/core/api/src/graphql/public/types/payload/user-update-username.ts +++ b/core/api/src/graphql/public/types/payload/user-update-username.ts @@ -1,6 +1,7 @@ -import IError from "../../../shared/types/abstract/error" import GraphQLUser from "../object/user" +import IError from "@/graphql/shared/types/abstract/error" + import { GT } from "@/graphql/index" const UserUpdateUsernamePayload = GT.Object({ diff --git a/core/api/src/services/mongoose/quiz.ts b/core/api/src/services/mongoose/quiz.ts index 0aec9a8795..93c92aeb73 100644 --- a/core/api/src/services/mongoose/quiz.ts +++ b/core/api/src/services/mongoose/quiz.ts @@ -1,5 +1,7 @@ import { Quiz } from "./schema" +import { QuizCompleted } from "@/domain/quiz" + import { QuizAlreadyPresentError, UnknownRepositoryError } from "@/domain/errors" interface ExtendedError extends Error { @@ -28,10 +30,12 @@ export const QuizRepository = () => { } } - const fetchAll = async (accountId: AccountId) => { + const fetchAll = async ( + accountId: AccountId, + ): Promise => { try { const result = await Quiz.find({ accountId }) - return result + return result.map(translateToQuiz) } catch (err) { return new UnknownRepositoryError(err) } @@ -42,3 +46,8 @@ export const QuizRepository = () => { fetchAll, } } + +const translateToQuiz = (result: QuizCompletedRecord): QuizCompleted => ({ + quizId: result.quizId as QuizQuestionId, + createdAt: new Date(result.createdAt), +}) diff --git a/core/api/src/services/mongoose/schema.ts b/core/api/src/services/mongoose/schema.ts index 2fe82c8ab1..e27b2a351e 100644 --- a/core/api/src/services/mongoose/schema.ts +++ b/core/api/src/services/mongoose/schema.ts @@ -303,7 +303,7 @@ AccountSchema.index({ export const Account = mongoose.model("Account", AccountSchema) -const QuizSchema = new Schema({ +const QuizSchema = new Schema({ accountId: { type: String, ref: "Account", @@ -321,7 +321,7 @@ const QuizSchema = new Schema({ QuizSchema.index({ accountId: 1, quizId: 1 }, { unique: true }) -export const Quiz = mongoose.model("Quiz", QuizSchema) +export const Quiz = mongoose.model("Quiz", QuizSchema) const AccountIpsSchema = new Schema({ ip: { diff --git a/core/api/src/services/mongoose/schema.types.d.ts b/core/api/src/services/mongoose/schema.types.d.ts index 68e230fdc2..56699547bc 100644 --- a/core/api/src/services/mongoose/schema.types.d.ts +++ b/core/api/src/services/mongoose/schema.types.d.ts @@ -102,7 +102,7 @@ interface AccountRecord { save: () => Promise } -interface QuizRecord { +interface QuizCompletedRecord { accountId: string quizId: string createdAt: Date diff --git a/core/api/test/integration/app/quizzes/add-quiz.spec.ts b/core/api/test/integration/app/quizzes/add-quiz.spec.ts index 232e0b1105..e1f3ba6f9a 100644 --- a/core/api/test/integration/app/quizzes/add-quiz.spec.ts +++ b/core/api/test/integration/app/quizzes/add-quiz.spec.ts @@ -18,7 +18,7 @@ afterEach(async () => { describe("addQuiz", () => { it("fails if ip is undefined", async () => { - const result = await Quiz.completeQuiz({ + const result = await Quiz.claimQuiz({ accountId: crypto.randomUUID() as AccountId, quizQuestionId: "fakeQuizQuestionId", ip: undefined, @@ -39,7 +39,7 @@ describe("addQuiz", () => { consume: () => new RateLimiterExceededError(), }) - const result = await Quiz.completeQuiz({ + const result = await Quiz.claimQuiz({ accountId: crypto.randomUUID() as AccountId, quizQuestionId: "fakeQuizQuestionId", ip: "192.168.13.13" as IpAddress, diff --git a/core/api/test/integration/services/quiz.spec.ts b/core/api/test/integration/services/quiz.spec.ts index d3b329d592..452c7e9100 100644 --- a/core/api/test/integration/services/quiz.spec.ts +++ b/core/api/test/integration/services/quiz.spec.ts @@ -29,11 +29,9 @@ describe("QuizRepository", () => { const result = await QuizRepository().fetchAll(accountId) expect(result).toMatchObject([ { - accountId, quizId: quizId, }, { - accountId, quizId: quiz2, }, ]) diff --git a/core/api/test/unit/domain/quiz/index.spec.ts b/core/api/test/unit/domain/quiz/index.spec.ts new file mode 100644 index 0000000000..7a1b3b1b93 --- /dev/null +++ b/core/api/test/unit/domain/quiz/index.spec.ts @@ -0,0 +1,120 @@ +import { fillQuizInformation } from "@/domain/quiz" + +describe("quiz", () => { + it("completed is false by default", () => { + const result = fillQuizInformation([]).quizzes + expect(result.find((quiz) => quiz.completed === true)).toBe(undefined) + }) + + it("default value when not started", () => { + const filledInfo = fillQuizInformation([]) + expect(filledInfo.currentSection).toBe(0) + expect(filledInfo.quizzes[0].notBefore).toBe(undefined) + }) + + it("test that we are on section 0 after 1 element has been completed", () => { + const quizCompleted = { quizId: "sat" as QuizQuestionId, createdAt: new Date() } + const filledInfo = fillQuizInformation([quizCompleted]) + + expect(filledInfo.quizzes.find((quiz) => quiz.completed === true)).toEqual({ + amount: 1, + completed: true, + id: "sat", + section: 0, + notBefore: undefined, + }) + + expect(filledInfo.currentSection).toBe(0) + }) + + it("test that we are on section 0 after 2 elements has been completed", () => { + const quizzesCompleted = [ + { quizId: "sat" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whereBitcoinExist" as QuizQuestionId, createdAt: new Date() }, + ] + const filledInfo = fillQuizInformation(quizzesCompleted) + + expect(filledInfo.quizzes.filter((quiz) => quiz.completed === true)).toEqual([ + { + amount: 1, + completed: true, + id: "sat", + section: 0, + notBefore: undefined, + }, + { + amount: 1, + completed: true, + id: "whereBitcoinExist", + section: 0, + notBefore: undefined, + }, + ]) + + expect(filledInfo.currentSection).toBe(0) + }) + + it("move to section 1 once all elements from 0 are completed", () => { + const quizzesCompleted = [ + { quizId: "whatIsBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "sat" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whereBitcoinExist" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whoControlsBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "copyBitcoin" as QuizQuestionId, createdAt: new Date() }, + ] + const filledInfo = fillQuizInformation(quizzesCompleted) + expect(filledInfo.currentSection).toBe(1) + + expect(filledInfo.quizzes[4].notBefore).toBeUndefined() + expect(filledInfo.quizzes[5].notBefore?.getTime()).toBeCloseTo( + new Date().getTime() + 12 * 60 * 60 * 1000, + -3, + ) + }) + + it("stay at section 1 when only some section 1 element are completed", () => { + const quizzesCompleted = [ + { quizId: "whatIsBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "sat" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whereBitcoinExist" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whoControlsBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "copyBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "moneySocialAgreement" as QuizQuestionId, createdAt: new Date() }, + { quizId: "coincidenceOfWants" as QuizQuestionId, createdAt: new Date() }, + ] + const filledInfo = fillQuizInformation(quizzesCompleted) + expect(filledInfo.currentSection).toBe(1) + + expect(filledInfo.quizzes[4].notBefore).toBeUndefined() + expect(filledInfo.quizzes[5].notBefore).toBeUndefined() + expect(filledInfo.quizzes[7].notBefore?.getTime()).toBeCloseTo( + new Date().getTime() + 12 * 60 * 60 * 1000, + -3, + ) + }) + + it("move to section 2 once all section 1 element are completed", () => { + const quizzesCompleted = [ + { quizId: "whatIsBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "sat" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whereBitcoinExist" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whoControlsBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "copyBitcoin" as QuizQuestionId, createdAt: new Date() }, + { quizId: "moneySocialAgreement" as QuizQuestionId, createdAt: new Date() }, + { quizId: "coincidenceOfWants" as QuizQuestionId, createdAt: new Date() }, + { quizId: "moneyEvolution" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whyStonesShellGold" as QuizQuestionId, createdAt: new Date() }, + { quizId: "moneyIsImportant" as QuizQuestionId, createdAt: new Date() }, + { quizId: "moneyImportantGovernement" as QuizQuestionId, createdAt: new Date() }, + ] + const filledInfo = fillQuizInformation(quizzesCompleted) + expect(filledInfo.currentSection).toBe(2) + + expect(filledInfo.quizzes[4].notBefore).toBeUndefined() + expect(filledInfo.quizzes[5].notBefore).toBeUndefined() + expect(filledInfo.quizzes[11].notBefore?.getTime()).toBeCloseTo( + new Date().getTime() + 12 * 60 * 60 * 1000, + -3, + ) + }) +}) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index 3da2c256b8..bcb5800a00 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -1168,7 +1168,8 @@ type Mutation onChainPaymentSendAll(input: OnChainPaymentSendAllInput!): PaymentSendPayload! @join__field(graph: PUBLIC) onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC) onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! @join__field(graph: PUBLIC) - quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @join__field(graph: PUBLIC) + quizClaim(input: QuizClaimInput!): QuizClaimPayload! @join__field(graph: PUBLIC) + quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @join__field(graph: PUBLIC) @deprecated(reason: "Use quizClaim instead") userContactUpdateAlias(input: UserContactUpdateAliasInput!): UserContactUpdateAliasPayload! @join__field(graph: PUBLIC) @deprecated(reason: "will be moved to AccountContact") userEmailDelete: UserEmailDeletePayload! @join__field(graph: PUBLIC) userEmailRegistrationInitiate(input: UserEmailRegistrationInitiateInput!): UserEmailRegistrationInitiatePayload! @join__field(graph: PUBLIC) @@ -1522,6 +1523,20 @@ type Quiz amount: SatAmount! completed: Boolean! id: ID! + notBefore: Timestamp +} + +input QuizClaimInput + @join__type(graph: PUBLIC) +{ + id: ID! +} + +type QuizClaimPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + quizzes: [Quiz!]! } input QuizCompletedInput