diff --git a/bats/core/api/quiz.bats b/bats/core/api/quiz.bats index 1f3bcab4d8..abb301bea0 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: new 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..0b5cfcce82 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 83% rename from core/api/src/app/quiz/add.ts rename to core/api/src/app/quiz/claim.ts index f289c1cd2b..caffabd652 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,6 +14,7 @@ import { MissingIPMetadataError, NoBtcWalletExistsForAccountError, NotEnoughBalanceForQuizError, + QuizClaimedTooEarlyError, UnauthorizedIPError, UnknownRepositoryError, } from "@/domain/errors" @@ -32,16 +35,26 @@ import { import { consumeLimiter } from "@/services/rate-limit" import { getFunderWalletId } from "@/services/ledger/caching" import { AccountsIpsRepository } from "@/services/mongoose/accounts-ips" +import { QuizQuestionId } from "@/domain/quiz/index.types" + +type ClaimQuizResult = { + id: QuizQuestionId + amount: Satoshis + completed: boolean + notBefore: Date | undefined +}[] -export const completeQuiz = async ({ +export const claimQuiz = async ({ quizQuestionId: quizQuestionIdString, accountId: accountIdRaw, ip, + legacy, }: { quizQuestionId: string accountId: string ip: IpAddress | undefined -}): Promise => { + legacy: boolean +}): Promise => { const check = await checkAddQuizAttemptPerIpLimits(ip) if (check instanceof Error) return check @@ -88,7 +101,18 @@ export const completeQuiz = async ({ if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata - return new UnknownRepositoryError("add quiz error") + return new UnknownRepositoryError("claim quiz error") + } + + const quizzesBefore = await listQuizzesByAccountId(accountId) + if (quizzesBefore instanceof Error) return quizzesBefore + + if (!legacy) { + 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) @@ -121,7 +145,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 ( 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/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 97% rename from core/api/src/domain/earn/config.ts rename to core/api/src/domain/quiz/config.ts index 2f07faace6..d8942ea968 100644 --- a/core/api/src/domain/earn/config.ts +++ b/core/api/src/domain/quiz/config.ts @@ -1,6 +1,8 @@ +import { QuizQuestionId } from "./index.types" + +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..81636087e5 --- /dev/null +++ b/core/api/src/domain/quiz/index.ts @@ -0,0 +1,78 @@ +export * from "./config" + +import { QuizzesValue, milliSecondsBetweenSections } from "./config" +import { QuizQuestionId } from "./index.types" +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 lastQuizCreatedAt = + quizzesCompleted[quizzesCompleted.length - 1]?.createdAt ?? new Date() + + const completed = Boolean(quizCompleted) + + let notBefore: Date | undefined = undefined + if (section !== 0 && !completed) { + 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..4a310cfd7c --- /dev/null +++ b/core/api/src/domain/quiz/index.types.d.ts @@ -0,0 +1,2 @@ +type QuizzesSectionsConfig = typeof import("./sections").QuizzesSectionsConfig +export type QuizQuestionId = QuizzesSectionsConfig[number]["quiz"][number] diff --git a/core/api/src/domain/quiz/script.ts b/core/api/src/domain/quiz/script.ts new file mode 100644 index 0000000000..e8e4d4bfe4 --- /dev/null +++ b/core/api/src/domain/quiz/script.ts @@ -0,0 +1,16 @@ +// pnpm tsx src/domain/earn/script.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/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..05832f6ef0 --- /dev/null +++ b/core/api/src/graphql/public/root/mutation/quiz-claim.ts @@ -0,0 +1,46 @@ +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, + legacy: false, + }) + if (quizzes instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(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..f6f00d4403 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,19 @@ const QuizCompletedMutation = GT.Field< resolve: async (_, args, { domainAccount, ip }) => { const { id } = args.input - const question = await Quiz.completeQuiz({ + const quizzes = await Quiz.claimQuiz({ quizQuestionId: id, accountId: domainAccount.id, ip, + legacy: true, }) - 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..f67d4b2f45 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..fd128d4790 100644 --- a/core/api/src/graphql/public/types/object/consumer-account.ts +++ b/core/api/src/graphql/public/types/object/consumer-account.ts @@ -34,7 +34,7 @@ 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" +import { listQuizzesByAccountId } from "@/app/quiz" const ConsumerAccount = GT.Object({ name: "ConsumerAccount", @@ -165,12 +165,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 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/quiz-claim.ts b/core/api/src/graphql/public/types/payload/quiz-claim.ts new file mode 100644 index 0000000000..2e9a36e904 --- /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.List(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..cb60f8d6a2 100644 --- a/core/api/src/graphql/public/types/payload/quiz-completed.ts +++ b/core/api/src/graphql/public/types/payload/quiz-completed.ts @@ -3,6 +3,7 @@ import Quiz from "../object/quiz" import { GT } from "@/graphql/index" +// deprecated const QuizCompletedPayload = GT.Object({ name: "QuizCompletedPayload", fields: () => ({ diff --git a/core/api/src/services/mongoose/quiz.ts b/core/api/src/services/mongoose/quiz.ts index 0aec9a8795..32f0d3849e 100644 --- a/core/api/src/services/mongoose/quiz.ts +++ b/core/api/src/services/mongoose/quiz.ts @@ -1,6 +1,9 @@ import { Quiz } from "./schema" +import { QuizCompleted } from "@/domain/quiz" + import { QuizAlreadyPresentError, UnknownRepositoryError } from "@/domain/errors" +import { QuizQuestionId } from "@/domain/quiz/index.types" interface ExtendedError extends Error { code?: number @@ -28,10 +31,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 +47,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..3d1e071dc4 100644 --- a/core/api/test/integration/app/quizzes/add-quiz.spec.ts +++ b/core/api/test/integration/app/quizzes/add-quiz.spec.ts @@ -18,10 +18,11 @@ 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, + legacy: true, }) expect(result).toBeInstanceOf(InvalidIpMetadataError) }) @@ -39,10 +40,11 @@ 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, + legacy: true, }) expect(result).toBeInstanceOf(UserAddQuizAttemptIpRateLimiterExceededError) diff --git a/core/api/test/integration/services/quiz.spec.ts b/core/api/test/integration/services/quiz.spec.ts index d3b329d592..2a50a730f9 100644 --- a/core/api/test/integration/services/quiz.spec.ts +++ b/core/api/test/integration/services/quiz.spec.ts @@ -2,6 +2,7 @@ import crypto from "crypto" import { QuizAlreadyPresentError } from "@/domain/errors" import { QuizRepository } from "@/services/mongoose" +import { QuizQuestionId } from "@/domain/quiz/index.types" describe("QuizRepository", () => { const accountId = crypto.randomUUID() as AccountId @@ -29,11 +30,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..7d66eb2128 --- /dev/null +++ b/core/api/test/unit/domain/quiz/index.spec.ts @@ -0,0 +1,115 @@ +import { fillQuizInformation } from "@/domain/quiz" +import { QuizQuestionId } from "@/domain/quiz/index.types" + +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("one element 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("two elements completed", () => { + const quizzesCompleted = [ + { quizId: "sat" as QuizQuestionId, createdAt: new Date() }, + { quizId: "whereBitcoinExist" as QuizQuestionId, createdAt: new Date() }, + ] + const filledInfo = fillQuizInformation(quizzesCompleted) + + 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("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) + + console.log(filledInfo.quizzes[4]) + console.log(filledInfo.quizzes[5]) + + 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..0b5cfcce82 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