diff --git a/bats/core/api/earn.bats b/bats/core/api/quiz.bats similarity index 75% rename from bats/core/api/earn.bats rename to bats/core/api/quiz.bats index 8414fcb3ab8..1f3bcab4d8b 100644 --- a/bats/core/api/earn.bats +++ b/bats/core/api/quiz.bats @@ -14,10 +14,9 @@ setup_file() { "alice.btc_wallet_id" \ "funder.btc_wallet_id" \ "10000" - } -@test "earn: completes a quiz question and gets paid once" { +@test "quiz: completes a quiz question and gets paid once" { token_name="alice" question_id="sat" @@ -29,7 +28,11 @@ setup_file() { .balance ') - # Do earn + exec_graphql $token_name 'quiz' + completed=$(graphql_output '.data.me.defaultAccount.quiz' | jq '.[] | select(.id == "sat") | .completed') + [[ "${completed}" == "false" ]] || exit 1 + + # Do quiz variables=$( jq -n \ --arg question_id "$question_id" \ @@ -41,21 +44,25 @@ setup_file() { quiz_completed=$(graphql_output '.data.quizCompleted.quiz.completed') [[ "${quiz_completed}" == "true" ]] || exit 1 + exec_graphql $token_name 'quiz' + completed=$(graphql_output '.data.me.defaultAccount.quiz' | jq '.[] | select(.id == "sat") | .completed') + [[ "${completed}" == "true" ]] || exit 1 + # Check balance after complete exec_graphql $token_name 'wallets-for-account' - btc_balance_after_earn=$(graphql_output ' + btc_balance_after_quiz=$(graphql_output ' .data.me.defaultAccount.wallets[] | select(.walletCurrency == "BTC") .balance ') - [[ "$btc_balance_after_earn" -gt "$btc_initial_balance" ]] || exit 1 + [[ "$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 earn + # Retry quiz exec_graphql "$token_name" 'quiz-question' "$variables" errors=$(graphql_output '.data.quizCompleted.errors') [[ "${errors}" != "null" ]] || exit 1 @@ -69,5 +76,5 @@ setup_file() { | select(.walletCurrency == "BTC") .balance ') - [[ "$btc_balance_after_retry" == "$btc_balance_after_earn" ]] || exit 1 + [[ "$btc_balance_after_retry" == "$btc_balance_after_quiz" ]] || exit 1 } diff --git a/bats/gql/quiz.gql b/bats/gql/quiz.gql new file mode 100644 index 00000000000..ebd2395d1fb --- /dev/null +++ b/bats/gql/quiz.gql @@ -0,0 +1,15 @@ +query myQuizQuestions { + me { + id + defaultAccount { + id + ... on ConsumerAccount { + quiz { + id + amount + completed + } + } + } + } +} \ No newline at end of file diff --git a/core/api/galoy.yaml b/core/api/galoy.yaml index 30a153c69fb..dff9132f377 100644 --- a/core/api/galoy.yaml +++ b/core/api/galoy.yaml @@ -18,7 +18,7 @@ buildVersion: ios: minBuildNumber: 362 lastBuildNumber: 362 -rewards: +quizzes: enableIpProxyCheck: true allowPhoneCountries: [] denyPhoneCountries: [] diff --git a/core/api/src/app/earn/index.ts b/core/api/src/app/earn/index.ts deleted file mode 100644 index 6192959ea2d..00000000000 --- a/core/api/src/app/earn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./add-earn" diff --git a/core/api/src/app/index.ts b/core/api/src/app/index.ts index 7e7939a3a87..356626c94c8 100644 --- a/core/api/src/app/index.ts +++ b/core/api/src/app/index.ts @@ -3,7 +3,7 @@ import * as AuthenticationMod from "./authentication" import * as AdminMod from "./admin" import * as CallbackMod from "./callback" import * as CommMod from "./comm" -import * as EarnMod from "./earn" +import * as QuizMod from "./quiz" import * as LightningMod from "./lightning" import * as OnChainMod from "./on-chain" import * as PricesMod from "./prices" @@ -21,7 +21,7 @@ const allFunctions = { Admin: { ...AdminMod }, Callback: { ...CallbackMod }, Comm: { ...CommMod }, - Earn: { ...EarnMod }, + Quiz: { ...QuizMod }, Lightning: { ...LightningMod }, OnChain: { ...OnChainMod }, Prices: { ...PricesMod }, @@ -51,7 +51,7 @@ export const { Admin, Callback, Comm, - Earn, + Quiz, Lightning, OnChain, Prices, diff --git a/core/api/src/app/earn/add-earn.ts b/core/api/src/app/quiz/add.ts similarity index 81% rename from core/api/src/app/earn/add-earn.ts rename to core/api/src/app/quiz/add.ts index f0f28421ad0..aaa72ab77a1 100644 --- a/core/api/src/app/earn/add-earn.ts +++ b/core/api/src/app/quiz/add.ts @@ -1,6 +1,8 @@ import { intraledgerPaymentSendWalletIdForBtcWallet } from "../payments/send-intraledger" -import { getRewardsConfig } from "@/config" +import { QuizzesValue } from "./config" + +import { getQuizzesConfig } from "@/config" import { getBalanceForWallet } from "@/app/wallets" @@ -9,7 +11,7 @@ import { InvalidQuizQuestionIdError, MissingIPMetadataError, NoBtcWalletExistsForAccountError, - NotEnoughBalanceForRewardError, + NotEnoughBalanceForQuizError, UnauthorizedIPError, UnknownRepositoryError, } from "@/domain/errors" @@ -17,22 +19,21 @@ import { WalletCurrency } from "@/domain/shared" import { RateLimitConfig } from "@/domain/rate-limit" import { checkedToAccountId } from "@/domain/accounts" import { PhoneMetadataAuthorizer } from "@/domain/users" -import { InvalidPhoneForRewardError } from "@/domain/users/errors" +import { InvalidPhoneForQuizError } from "@/domain/users/errors" import { RateLimiterExceededError } from "@/domain/rate-limit/errors" import { IPMetadataAuthorizer } from "@/domain/accounts-ips/ip-metadata-authorizer" import { AccountsRepository, - RewardsRepository, + QuizRepository, WalletsRepository, UsersRepository, } from "@/services/mongoose" import { consumeLimiter } from "@/services/rate-limit" import { getFunderWalletId } from "@/services/ledger/caching" import { AccountsIpsRepository } from "@/services/mongoose/accounts-ips" -import { OnboardingEarn } from "./config" -export const addEarn = async ({ +export const completeQuiz = async ({ quizQuestionId: quizQuestionIdString, accountId: accountIdRaw, ip, @@ -41,18 +42,18 @@ export const addEarn = async ({ accountId: string ip: IpAddress | undefined }): Promise => { - const check = await checkAddEarnAttemptPerIpLimits(ip) + const check = await checkAddQuizAttemptPerIpLimits(ip) if (check instanceof Error) return check const accountId = checkedToAccountId(accountIdRaw) if (accountId instanceof Error) return accountId - const rewardsConfig = getRewardsConfig() + const quizzesConfig = getQuizzesConfig() // TODO: quizQuestionId checkedFor const quizQuestionId = quizQuestionIdString as QuizQuestionId - const amount = OnboardingEarn[quizQuestionId] + const amount = QuizzesValue[quizQuestionId] if (!amount) return new InvalidQuizQuestionIdError() const funderWalletId = await getFunderWalletId() @@ -68,18 +69,18 @@ export const addEarn = async ({ if (user instanceof Error) return user const validatedPhoneMetadata = PhoneMetadataAuthorizer( - rewardsConfig.phoneMetadataValidationSettings, + quizzesConfig.phoneMetadataValidationSettings, ).authorize(user.phoneMetadata) if (validatedPhoneMetadata instanceof Error) { - return new InvalidPhoneForRewardError(validatedPhoneMetadata.name) + return new InvalidPhoneForQuizError(validatedPhoneMetadata.name) } const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id) if (accountIP instanceof Error) return accountIP const validatedIPMetadata = IPMetadataAuthorizer( - rewardsConfig.ipMetadataValidationSettings, + quizzesConfig.ipMetadataValidationSettings, ).authorize(accountIP.metadata) if (validatedIPMetadata instanceof Error) { if (validatedIPMetadata instanceof MissingIPMetadataError) @@ -87,7 +88,7 @@ export const addEarn = async ({ if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata - return new UnknownRepositoryError("add earn error") + return new UnknownRepositoryError("add quiz error") } const recipientWallets = await WalletsRepository().listByAccountId(accountId) @@ -99,8 +100,8 @@ export const addEarn = async ({ if (recipientBtcWallet === undefined) return new NoBtcWalletExistsForAccountError() const recipientWalletId = recipientBtcWallet.id - const shouldGiveReward = await RewardsRepository(accountId).add(quizQuestionId) - if (shouldGiveReward instanceof Error) return shouldGiveReward + const shouldGiveSats = await QuizRepository(accountId).add(quizQuestionId) + if (shouldGiveSats instanceof Error) return shouldGiveSats const funderBalance = await getBalanceForWallet({ walletId: funderWalletId }) if (funderBalance instanceof Error) return funderBalance @@ -123,13 +124,13 @@ export const addEarn = async ({ return { id: quizQuestionId, earnAmount: amount } } -const checkAddEarnAttemptPerIpLimits = async ( +const checkAddQuizAttemptPerIpLimits = async ( ip: IpAddress | undefined, ): Promise => { if (!ip) return new InvalidIpMetadataError() return consumeLimiter({ - rateLimitConfig: RateLimitConfig.addEarnAttemptPerIp, + rateLimitConfig: RateLimitConfig.addQuizAttemptPerIp, keyToConsume: ip, }) } @@ -143,7 +144,7 @@ const FunderBalanceChecker = () => { amountToSend: Satoshis }): ValidationError | true => { if (balance < amountToSend) { - return new NotEnoughBalanceForRewardError(JSON.stringify({ balance, amountToSend })) + return new NotEnoughBalanceForQuizError(JSON.stringify({ balance, amountToSend })) } return true diff --git a/core/api/src/app/earn/config.ts b/core/api/src/app/quiz/config.ts similarity index 98% rename from core/api/src/app/earn/config.ts rename to core/api/src/app/quiz/config.ts index 91edda329e5..e4deb83c3b7 100644 --- a/core/api/src/app/earn/config.ts +++ b/core/api/src/app/quiz/config.ts @@ -1,5 +1,4 @@ -// onboarding -export const OnboardingEarn: Record = { +export const QuizzesValue: Record = { walletDownloaded: 1 as Satoshis, walletActivated: 1 as Satoshis, whatIsBitcoin: 1 as Satoshis, diff --git a/core/api/src/app/quiz/index.ts b/core/api/src/app/quiz/index.ts new file mode 100644 index 00000000000..363cc3148e1 --- /dev/null +++ b/core/api/src/app/quiz/index.ts @@ -0,0 +1 @@ +export * from "./add" diff --git a/core/api/src/config/index.ts b/core/api/src/config/index.ts index 4f92efec883..5b08de0e8c9 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 { OnboardingEarn } from "@/app/earn/config" +import { QuizzesValue } from "@/app/quiz/config" export const MS_PER_SEC = 1000 as MilliSeconds export const MS_PER_5_MINS = (60 * 5 * MS_PER_SEC) as MilliSeconds @@ -85,7 +85,7 @@ export const getLoopConfig = () => { export const memoSharingConfig = { memoSharingSatsThreshold: MEMO_SHARING_SATS_THRESHOLD, memoSharingCentsThreshold: MEMO_SHARING_CENTS_THRESHOLD, - authorizedMemos: Object.keys(OnboardingEarn), + authorizedMemos: Object.keys(QuizzesValue), } as const export const getCallbackServiceConfig = (): SvixConfig => { diff --git a/core/api/src/config/schema.ts b/core/api/src/config/schema.ts index 8cde63432a6..190a2f5a5de 100644 --- a/core/api/src/config/schema.ts +++ b/core/api/src/config/schema.ts @@ -107,7 +107,7 @@ export const configSchema = { required: ["ios", "android"], additionalProperties: false, }, - rewards: { + quizzes: { type: "object", properties: { enableIpProxyCheck: { type: "boolean" }, @@ -265,7 +265,7 @@ export const configSchema = { onChainAddressCreateAttempt: rateLimitConfigSchema, deviceAccountCreateAttempt: rateLimitConfigSchema, requestCodePerAppcheckJti: rateLimitConfigSchema, - addEarnPerIp: rateLimitConfigSchema, + addQuizPerIp: rateLimitConfigSchema, }, required: [ "requestCodePerLoginIdentifier", @@ -277,7 +277,7 @@ export const configSchema = { "onChainAddressCreateAttempt", "deviceAccountCreateAttempt", "requestCodePerAppcheckJti", - "addEarnPerIp", + "addQuizPerIp", ], additionalProperties: false, default: { @@ -326,7 +326,7 @@ export const configSchema = { duration: 3600, blockDuration: 3600, }, - addEarnPerIp: { + addQuizPerIp: { points: 125, duration: 86400, blockDuration: 604800, @@ -645,7 +645,7 @@ export const configSchema = { "dealer", "ratioPrecision", "buildVersion", - "rewards", + "quizzes", "coldStorage", "bria", "lndScbBackupBucketName", diff --git a/core/api/src/config/schema.types.d.ts b/core/api/src/config/schema.types.d.ts index f3b4cc27a2d..c42544a2830 100644 --- a/core/api/src/config/schema.types.d.ts +++ b/core/api/src/config/schema.types.d.ts @@ -36,7 +36,7 @@ type YamlSchema = { android: BuildNumberInput ios: BuildNumberInput } - rewards: { + quizzes: { enableIpProxyCheck: boolean denyPhoneCountries: string[] allowPhoneCountries: string[] @@ -79,7 +79,7 @@ type YamlSchema = { onChainAddressCreateAttempt: RateLimitInput deviceAccountCreateAttempt: RateLimitInput requestCodePerAppcheckJti: RateLimitInput - addEarnPerIp: RateLimitInput + addQuizPerIp: RateLimitInput } accounts: { initialStatus: string diff --git a/core/api/src/config/types.d.ts b/core/api/src/config/types.d.ts index 0052969f54b..a30607426ae 100644 --- a/core/api/src/config/types.d.ts +++ b/core/api/src/config/types.d.ts @@ -14,7 +14,7 @@ type CaptchaConfig = { mandatory: boolean } -type RewardsConfig = { +type QuizzesConfig = { phoneMetadataValidationSettings: PhoneMetadataValidationSettings ipMetadataValidationSettings: IpMetadataValidationSettings } diff --git a/core/api/src/config/yaml.ts b/core/api/src/config/yaml.ts index 5b20b95fa1c..5448305a798 100644 --- a/core/api/src/config/yaml.ts +++ b/core/api/src/config/yaml.ts @@ -212,8 +212,8 @@ export const getDeviceAccountCreateAttemptLimits = () => export const getAppcheckJtiAttemptLimits = () => getRateLimits(yamlConfig.rateLimits.requestCodePerAppcheckJti) -export const getAddEarnPerIpLimits = () => - getRateLimits(yamlConfig.rateLimits.addEarnPerIp) +export const getAddQuizPerIpLimits = () => + getRateLimits(yamlConfig.rateLimits.addQuizPerIp) export const getOnChainWalletConfig = () => ({ dustThreshold: yamlConfig.onChainWallet.dustThreshold, @@ -268,13 +268,13 @@ export const getCronConfig = (config = yamlConfig): CronConfig => config.cronCon export const getCaptcha = (config = yamlConfig): CaptchaConfig => config.captcha -export const getRewardsConfig = (): RewardsConfig => { - const denyPhoneCountries = yamlConfig.rewards.denyPhoneCountries || [] - const allowPhoneCountries = yamlConfig.rewards.allowPhoneCountries || [] - const denyIPCountries = yamlConfig.rewards.denyIPCountries || [] - const allowIPCountries = yamlConfig.rewards.allowIPCountries || [] - const denyASNs = yamlConfig.rewards.denyASNs || [] - const allowASNs = yamlConfig.rewards.allowASNs || [] +export const getQuizzesConfig = (): QuizzesConfig => { + const denyPhoneCountries = yamlConfig.quizzes.denyPhoneCountries || [] + const allowPhoneCountries = yamlConfig.quizzes.allowPhoneCountries || [] + const denyIPCountries = yamlConfig.quizzes.denyIPCountries || [] + const allowIPCountries = yamlConfig.quizzes.allowIPCountries || [] + const denyASNs = yamlConfig.quizzes.denyASNs || [] + const allowASNs = yamlConfig.quizzes.allowASNs || [] return { phoneMetadataValidationSettings: { @@ -286,7 +286,7 @@ export const getRewardsConfig = (): RewardsConfig => { allowCountries: allowIPCountries.map((c) => c.toUpperCase()), denyASNs: denyASNs.map((c) => c.toUpperCase()), allowASNs: allowASNs.map((c) => c.toUpperCase()), - checkProxy: yamlConfig.rewards.enableIpProxyCheck, + checkProxy: yamlConfig.quizzes.enableIpProxyCheck, }, } } diff --git a/core/api/src/domain/accounts/index.types.d.ts b/core/api/src/domain/accounts/index.types.d.ts index fb38e2297aa..b9d8747b90e 100644 --- a/core/api/src/domain/accounts/index.types.d.ts +++ b/core/api/src/domain/accounts/index.types.d.ts @@ -107,7 +107,6 @@ type Account = { coordinates: Coordinates | null contactEnabled: boolean readonly contacts: AccountContact[] - readonly quiz: Quiz[] notificationSettings: NotificationSettings kratosUserId: UserId displayCurrency: DisplayCurrency diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index 98f6e440e41..4266c056a94 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -66,7 +66,7 @@ export class CouldNotFindAccountFromPhoneError extends CouldNotFindError {} export class CouldNotFindTransactionsForAccountError extends CouldNotFindError {} export class CouldNotFindAccountFromKratosIdError extends CouldNotFindError {} -export class RewardAlreadyPresentError extends DomainError {} +export class QuizAlreadyPresentError extends DomainError {} export class NotImplementedError extends DomainError {} export class NotReachableError extends DomainError {} @@ -116,7 +116,7 @@ export class MissingIPMetadataError extends ValidationError {} export class InvalidIpMetadataError extends ValidationError { level = ErrorLevel.Critical } -export class NotEnoughBalanceForRewardError extends ValidationError { +export class NotEnoughBalanceForQuizError extends ValidationError { level = ErrorLevel.Warn } diff --git a/core/api/src/domain/rate-limit/errors.ts b/core/api/src/domain/rate-limit/errors.ts index 9de6139f864..ce3962a5c42 100644 --- a/core/api/src/domain/rate-limit/errors.ts +++ b/core/api/src/domain/rate-limit/errors.ts @@ -18,4 +18,4 @@ export class InvoiceCreateForRecipientRateLimiterExceededError extends RateLimit export class OnChainAddressCreateRateLimiterExceededError extends RateLimiterExceededError {} export class DeviceAccountCreateRateLimiterExceededError extends RateLimiterExceededError {} export class UserCodeAttemptAppcheckJtiLimiterExceededError extends RateLimiterExceededError {} -export class UserAddEarnAttemptIpRateLimiterExceededError extends RateLimiterExceededError {} +export class UserAddQuizAttemptIpRateLimiterExceededError extends RateLimiterExceededError {} diff --git a/core/api/src/domain/rate-limit/index.ts b/core/api/src/domain/rate-limit/index.ts index 187b8d4c3b1..8d954dbe5ec 100644 --- a/core/api/src/domain/rate-limit/index.ts +++ b/core/api/src/domain/rate-limit/index.ts @@ -8,7 +8,7 @@ import { UserCodeAttemptIdentifierRateLimiterExceededError, DeviceAccountCreateRateLimiterExceededError, UserCodeAttemptAppcheckJtiLimiterExceededError, - UserAddEarnAttemptIpRateLimiterExceededError, + UserAddQuizAttemptIpRateLimiterExceededError, } from "./errors" import { @@ -21,7 +21,7 @@ import { getRequestCodePerIpLimits, getRequestCodePerLoginIdentifierLimits, getAppcheckJtiAttemptLimits, - getAddEarnPerIpLimits, + getAddQuizPerIpLimits, } from "@/config" export const RateLimitPrefix = { @@ -34,7 +34,7 @@ export const RateLimitPrefix = { onChainAddressCreate: "onchain_address_create", deviceAccountCreate: "device_account_create", requestCodeAttemptPerAppcheckJti: "request_code_attempt_appcheck_jti", - addEarnAttemptPerIp: "add_earn_attempt_ip", + addQuizAttemptPerIp: "add_quiz_attempt_ip", } as const type RateLimitPrefixKey = keyof typeof RateLimitPrefix @@ -85,9 +85,9 @@ export const RateLimitConfig: { [key in RateLimitPrefixKey]: RateLimitConfig } = limits: getAppcheckJtiAttemptLimits(), error: UserCodeAttemptAppcheckJtiLimiterExceededError, }, - addEarnAttemptPerIp: { - key: RateLimitPrefix.addEarnAttemptPerIp, - limits: getAddEarnPerIpLimits(), - error: UserAddEarnAttemptIpRateLimiterExceededError, + addQuizAttemptPerIp: { + key: RateLimitPrefix.addQuizAttemptPerIp, + limits: getAddQuizPerIpLimits(), + error: UserAddQuizAttemptIpRateLimiterExceededError, }, } diff --git a/core/api/src/domain/users/errors.ts b/core/api/src/domain/users/errors.ts index 39b9e2ce144..6e17db2b630 100644 --- a/core/api/src/domain/users/errors.ts +++ b/core/api/src/domain/users/errors.ts @@ -7,7 +7,7 @@ export class PhoneCarrierTypeNotAllowedError extends UnauthorizedPhoneError {} export class PhoneCountryNotAllowedError extends UnauthorizedPhoneError {} export class InvalidPhoneForOnboardingError extends UnauthorizedPhoneError {} -export class InvalidPhoneForRewardError extends UnauthorizedPhoneError {} +export class InvalidPhoneForQuizError extends UnauthorizedPhoneError {} export class InvalidPhoneMetadataForOnboardingError extends UnauthorizedPhoneError { level = ErrorLevel.Critical } diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index d3861532c97..7b16e5abae8 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -274,7 +274,7 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { "Too many login attempts on same network, please wait for a while and try again." return new TooManyRequestError({ message, logger: baseLogger }) - case "UserAddEarnAttemptIpRateLimiterExceededError": + case "UserAddQuizAttemptIpRateLimiterExceededError": message = "Too many attempts, please wait for a while and try again." return new TooManyRequestError({ message, logger: baseLogger }) @@ -286,13 +286,13 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = "Invalid push notification setting was passed." return new ValidationInternalError({ message, logger: baseLogger }) - case "RewardAlreadyPresentError": - message = "Reward for quiz question was already claimed." + case "QuizAlreadyPresentError": + message = "Quiz question was already claimed." return new ValidationInternalError({ message, logger: baseLogger }) - case "NotEnoughBalanceForRewardError": + case "NotEnoughBalanceForQuizError": message = - "Rewards wallet temporarily depleted. Please contact support if problem persists." + "Quiz wallet temporarily depleted. Please contact support if problem persists." return new ValidationInternalError({ message, logger: baseLogger }) case "SubOneCentSatAmountForUsdSelfSendError": @@ -300,8 +300,8 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = "Amount sent was too low for recipient's usd wallet." return new ValidationInternalError({ message, logger: baseLogger }) - case "InvalidPhoneForRewardError": - message = "Unsupported phone carrier for rewards." + case "InvalidPhoneForQuizError": + message = "Unsupported phone carrier for quiz." return new ValidationInternalError({ message, logger: baseLogger }) case "InvalidIpMetadataError": diff --git a/core/api/src/graphql/error.ts b/core/api/src/graphql/error.ts index 3e2e88c62f8..a56c24bf219 100644 --- a/core/api/src/graphql/error.ts +++ b/core/api/src/graphql/error.ts @@ -381,9 +381,9 @@ export class InvalidPhoneForOnboardingError extends CustomGraphQLError { export class UnauthorizedIPMetadataCountryError extends CustomGraphQLError { constructor(errData: CustomGraphQLErrorData) { super({ - message: "Country not not authorized for rewards.", + message: "Country not not authorized for quizzes.", forwardToClient: true, - code: "UNAUTHORIZED_COUNTRY_IP_FOR_REWARD", + code: "UNAUTHORIZED_COUNTRY_IP_FOR_QUIZZES", ...errData, }) } @@ -392,9 +392,9 @@ export class UnauthorizedIPMetadataCountryError extends CustomGraphQLError { export class UnauthorizedIPMetadataProxyError extends CustomGraphQLError { constructor(errData: CustomGraphQLErrorData) { super({ - message: "VPN ips are not authorized for rewards.", + message: "VPN ips are not authorized for quizzes.", forwardToClient: true, - code: "UNAUTHORIZED_VPN_IP_FOR_REWARD", + code: "UNAUTHORIZED_VPN_IP_FOR_QUIZZES", ...errData, }) } @@ -403,9 +403,9 @@ export class UnauthorizedIPMetadataProxyError extends CustomGraphQLError { export class UnauthorizedIPError extends CustomGraphQLError { constructor(errData: CustomGraphQLErrorData) { super({ - message: "This ip is unauthorized for rewards.", + message: "This ip is unauthorized for quizzes.", forwardToClient: true, - code: "UNAUTHORIZED_IP_FOR_REWARD", + code: "UNAUTHORIZED_IP_FOR_QUIZZES", ...errData, }) } 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 3cb804096b3..0c932b6905f 100644 --- a/core/api/src/graphql/public/root/mutation/quiz-completed.ts +++ b/core/api/src/graphql/public/root/mutation/quiz-completed.ts @@ -1,4 +1,4 @@ -import { Earn } from "@/app" +import { Quiz } from "@/app" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" import { GT } from "@/graphql/index" @@ -26,7 +26,7 @@ const QuizCompletedMutation = GT.Field< resolve: async (_, args, { domainAccount, ip }) => { const { id } = args.input - const question = await Earn.addEarn({ + const question = await Quiz.completeQuiz({ quizQuestionId: id, accountId: domainAccount.id, ip, 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 a4abb4b02e7..bc2e04720ce 100644 --- a/core/api/src/graphql/public/types/object/consumer-account.ts +++ b/core/api/src/graphql/public/types/object/consumer-account.ts @@ -35,6 +35,8 @@ 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 "@/services/quiz" + const ConsumerAccount = GT.Object({ name: "ConsumerAccount", interfaces: () => [IAccount], @@ -167,7 +169,16 @@ const ConsumerAccount = GT.Object({ quiz: { type: GT.NonNullList(Quiz), description: "List the quiz questions of the consumer account", - resolve: (source) => source.quiz, + resolve: async (source) => { + const accountId = source.id + const result = await getQuizzesByAccountId(accountId) + + if (result instanceof Error) { + throw mapError(result) + } + + return result + }, }, transactions: { diff --git a/core/api/src/services/mongoose/accounts.ts b/core/api/src/services/mongoose/accounts.ts index 00df21cbb82..8fbed812535 100644 --- a/core/api/src/services/mongoose/accounts.ts +++ b/core/api/src/services/mongoose/accounts.ts @@ -1,4 +1,3 @@ -import { OnboardingEarn } from "@/app/earn/config" import { parseRepositoryError } from "./utils" import { AccountStatus } from "@/domain/accounts" @@ -218,12 +217,6 @@ const translateToAccount = (result: AccountRecord): Account => ({ }, }, - quiz: Object.entries(OnboardingEarn).map(([id, amount]) => ({ - id: id as QuizQuestionId, - amount, - completed: result.earn.indexOf(id) > -1, - })), - kratosUserId: result.kratosUserId as UserId, displayCurrency: (result.displayCurrency || UsdDisplayCurrency) as DisplayCurrency, }) diff --git a/core/api/src/services/mongoose/index.ts b/core/api/src/services/mongoose/index.ts index 2b7687103ef..75b49f12fb5 100644 --- a/core/api/src/services/mongoose/index.ts +++ b/core/api/src/services/mongoose/index.ts @@ -1,7 +1,7 @@ export * from "./accounts" export * from "./ln-payments" export * from "./payment-flow" -export * from "./rewards" +export * from "./quiz" export * from "./users" export * from "./wallets" export * from "./wallet-invoices" diff --git a/core/api/src/services/mongoose/quiz.ts b/core/api/src/services/mongoose/quiz.ts new file mode 100644 index 00000000000..b7e4aae092e --- /dev/null +++ b/core/api/src/services/mongoose/quiz.ts @@ -0,0 +1,38 @@ +import { Quiz } from "./schema" + +import { QuizAlreadyPresentError, UnknownRepositoryError } from "@/domain/errors" + +interface ExtendedError extends Error { + code?: number +} + +export const QuizRepository = (accountId: AccountId) => { + const add = async (quizId: QuizQuestionId) => { + try { + await Quiz.create({ accountId, quizId }) + + return true + } catch (err) { + if (err instanceof Error) { + const error = err as ExtendedError + if (error?.code === 11000) return new QuizAlreadyPresentError() + } + + return new UnknownRepositoryError("quiz issue") + } + } + + const fetchAll = async () => { + try { + const result = await Quiz.find({ accountId }) + return result + } catch (err) { + return new UnknownRepositoryError("quiz issue") + } + } + + return { + add, + fetchAll, + } +} diff --git a/core/api/src/services/mongoose/rewards.ts b/core/api/src/services/mongoose/rewards.ts deleted file mode 100644 index f4658dd1828..00000000000 --- a/core/api/src/services/mongoose/rewards.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Account } from "./schema" - -import { RewardAlreadyPresentError, UnknownRepositoryError } from "@/domain/errors" - -// FIXME: improve boundary -export const RewardsRepository = (accountId: AccountId) => { - const add = async (quizQuestionId: QuizQuestionId) => { - try { - // by default, mongodb return the previous state before the update - const oldState = await Account.findOneAndUpdate( - { id: accountId }, - { $push: { earn: quizQuestionId } }, - // { upsert: true }, - ) - - if (!oldState) { - return new UnknownRepositoryError("account not found") - } - - const rewardNotFound = - oldState.earn.findIndex((item) => item === quizQuestionId) === -1 - - return rewardNotFound || new RewardAlreadyPresentError() - } catch (err) { - return new UnknownRepositoryError("reward issue") - } - } - - return { - add, - } -} diff --git a/core/api/src/services/mongoose/schema.ts b/core/api/src/services/mongoose/schema.ts index afede0eb8a9..2fe82c8ab17 100644 --- a/core/api/src/services/mongoose/schema.ts +++ b/core/api/src/services/mongoose/schema.ts @@ -158,10 +158,6 @@ const AccountSchema = new Schema( type: Date, default: Date.now, }, - earn: { - type: [String], - default: [], - }, role: { type: String, // FIXME: role is a mix between 2 things here @@ -307,6 +303,26 @@ AccountSchema.index({ export const Account = mongoose.model("Account", AccountSchema) +const QuizSchema = new Schema({ + accountId: { + type: String, + ref: "Account", + required: true, + }, + quizId: { + type: String, + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}) + +QuizSchema.index({ accountId: 1, quizId: 1 }, { unique: true }) + +export const Quiz = mongoose.model("Quiz", QuizSchema) + const AccountIpsSchema = new Schema({ ip: { type: String, diff --git a/core/api/src/services/mongoose/schema.types.d.ts b/core/api/src/services/mongoose/schema.types.d.ts index 757d3e071f9..68e230fdc24 100644 --- a/core/api/src/services/mongoose/schema.types.d.ts +++ b/core/api/src/services/mongoose/schema.types.d.ts @@ -86,7 +86,6 @@ interface AccountRecord { statusHistory: AccountStatusHistory withdrawFee?: number - earn: string[] contactEnabled: boolean contacts: ContactObjectForUser[] created_at: Date @@ -103,6 +102,12 @@ interface AccountRecord { save: () => Promise } +interface QuizRecord { + accountId: string + quizId: string + createdAt: Date +} + interface AccountIpsRecord { ip: string metadata: { diff --git a/core/api/src/services/quiz/index.ts b/core/api/src/services/quiz/index.ts new file mode 100644 index 00000000000..53e5f3d7797 --- /dev/null +++ b/core/api/src/services/quiz/index.ts @@ -0,0 +1,18 @@ +import { QuizRepository } from "../mongoose" + +import { QuizzesValue } from "@/app/quiz/config" + +export const getQuizzesByAccountId = async (accountId: AccountId) => { + const quizzes = await QuizRepository(accountId).fetchAll() + 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/test/integration/app/payments/add-earn.spec.ts b/core/api/test/integration/app/quizzes/add-quiz.spec.ts similarity index 75% rename from core/api/test/integration/app/payments/add-earn.spec.ts rename to core/api/test/integration/app/quizzes/add-quiz.spec.ts index fc6bf6d567f..232e0b11053 100644 --- a/core/api/test/integration/app/payments/add-earn.spec.ts +++ b/core/api/test/integration/app/quizzes/add-quiz.spec.ts @@ -1,11 +1,11 @@ import crypto from "crypto" -import { Earn } from "@/app" +import { Quiz } from "@/app" import { InvalidIpMetadataError } from "@/domain/errors" import { RateLimiterExceededError, - UserAddEarnAttemptIpRateLimiterExceededError, + UserAddQuizAttemptIpRateLimiterExceededError, } from "@/domain/rate-limit/errors" import * as RateLimitImpl from "@/services/rate-limit" @@ -16,9 +16,9 @@ afterEach(async () => { jest.restoreAllMocks() }) -describe("addEarn", () => { +describe("addQuiz", () => { it("fails if ip is undefined", async () => { - const result = await Earn.addEarn({ + const result = await Quiz.completeQuiz({ accountId: crypto.randomUUID() as AccountId, quizQuestionId: "fakeQuizQuestionId", ip: undefined, @@ -33,19 +33,19 @@ describe("addEarn", () => { .spyOn(RateLimitImpl, "RedisRateLimitService") .mockReturnValue({ ...RedisRateLimitService({ - keyPrefix: RateLimitConfig.addEarnAttemptPerIp.key, - limitOptions: RateLimitConfig.addEarnAttemptPerIp.limits, + keyPrefix: RateLimitConfig.addQuizAttemptPerIp.key, + limitOptions: RateLimitConfig.addQuizAttemptPerIp.limits, }), consume: () => new RateLimiterExceededError(), }) - const result = await Earn.addEarn({ + const result = await Quiz.completeQuiz({ accountId: crypto.randomUUID() as AccountId, quizQuestionId: "fakeQuizQuestionId", ip: "192.168.13.13" as IpAddress, }) - expect(result).toBeInstanceOf(UserAddEarnAttemptIpRateLimiterExceededError) + expect(result).toBeInstanceOf(UserAddQuizAttemptIpRateLimiterExceededError) // Restore system state rateLimitServiceSpy.mockReset() diff --git a/core/api/test/integration/services/quiz.spec.ts b/core/api/test/integration/services/quiz.spec.ts new file mode 100644 index 00000000000..b6fe7abe676 --- /dev/null +++ b/core/api/test/integration/services/quiz.spec.ts @@ -0,0 +1,41 @@ +import crypto from "crypto" + +import { QuizAlreadyPresentError } from "@/domain/errors" +import { QuizRepository } from "@/services/mongoose" + +describe("QuizRepository", () => { + const accountId = crypto.randomUUID() as AccountId + const quizQuestionId = "fakeQuizQuestionId" as QuizQuestionId + + it("add quiz", async () => { + const result = await QuizRepository(accountId).add(quizQuestionId) + expect(result).toBe(true) + }) + + it("can't add quiz twice", async () => { + const result = await QuizRepository(accountId).add(quizQuestionId) + expect(result).toBeInstanceOf(QuizAlreadyPresentError) + }) + + it("fetch quiz", async () => { + const result = await QuizRepository(accountId).fetchAll() + expect(result).toHaveLength(1) + }) + + it("fetch quizzes", async () => { + const quiz2 = "fakeQuizQuestionId2" as QuizQuestionId + await QuizRepository(accountId).add(quiz2) + + const result = await QuizRepository(accountId).fetchAll() + expect(result).toMatchObject([ + { + accountId, + quizId: quizQuestionId, + }, + { + accountId, + quizId: quiz2, + }, + ]) + }) +}) diff --git a/core/api/test/unit/domain/users/phone-metadata-authorizer.spec.ts b/core/api/test/unit/domain/users/phone-metadata-authorizer.spec.ts index 12ce0f3c6ee..2ab409228cf 100644 --- a/core/api/test/unit/domain/users/phone-metadata-authorizer.spec.ts +++ b/core/api/test/unit/domain/users/phone-metadata-authorizer.spec.ts @@ -1,4 +1,4 @@ -import { getRewardsConfig, yamlConfig } from "@/config" +import { getQuizzesConfig, yamlConfig } from "@/config" import { PhoneCountryNotAllowedError, PhoneCarrierTypeNotAllowedError, @@ -7,7 +7,7 @@ import { import { PhoneMetadataAuthorizer } from "@/domain/users" beforeEach(async () => { - yamlConfig.rewards = { + yamlConfig.quizzes = { denyPhoneCountries: ["in"], allowPhoneCountries: ["sv", "US"], denyIPCountries: [], @@ -18,8 +18,8 @@ beforeEach(async () => { } }) -const getPhoneMetadataRewardsSettings = () => - getRewardsConfig().phoneMetadataValidationSettings +const getPhoneMetadataQuizzesSettings = () => + getQuizzesConfig().phoneMetadataValidationSettings describe("PhoneMetadataAuthorizer - validate", () => { it("returns true for empty config", () => { @@ -42,7 +42,7 @@ describe("PhoneMetadataAuthorizer - validate", () => { it("returns true for a valid country", () => { const authorizersSV = PhoneMetadataAuthorizer( - getPhoneMetadataRewardsSettings(), + getPhoneMetadataQuizzesSettings(), ).authorize({ carrier: { error_code: "", @@ -56,7 +56,7 @@ describe("PhoneMetadataAuthorizer - validate", () => { expect(authorizersSV).toBe(true) const authorizersSV1 = PhoneMetadataAuthorizer( - getPhoneMetadataRewardsSettings(), + getPhoneMetadataQuizzesSettings(), ).authorize({ carrier: { error_code: "", @@ -70,7 +70,7 @@ describe("PhoneMetadataAuthorizer - validate", () => { expect(authorizersSV1).toBe(true) const validatorUS = PhoneMetadataAuthorizer( - getPhoneMetadataRewardsSettings(), + getPhoneMetadataQuizzesSettings(), ).authorize({ carrier: { error_code: "", @@ -102,7 +102,7 @@ describe("PhoneMetadataAuthorizer - validate", () => { it("returns error for invalid country", () => { const validator = PhoneMetadataAuthorizer( - getPhoneMetadataRewardsSettings(), + getPhoneMetadataQuizzesSettings(), ).authorize({ carrier: { error_code: "", @@ -154,14 +154,14 @@ describe("PhoneMetadataAuthorizer - validate", () => { it("returns error with undefined metadata", () => { const validator = PhoneMetadataAuthorizer( - getPhoneMetadataRewardsSettings(), + getPhoneMetadataQuizzesSettings(), ).authorize(undefined) expect(validator).toBeInstanceOf(ExpectedPhoneMetadataMissingError) }) it("returns error with voip type", () => { const validator = PhoneMetadataAuthorizer( - getPhoneMetadataRewardsSettings(), + getPhoneMetadataQuizzesSettings(), ).authorize({ carrier: { error_code: "", diff --git a/core/api/test/unit/domain/wallets/payment-input-validator.spec.ts b/core/api/test/unit/domain/wallets/payment-input-validator.spec.ts index 14da2f29e02..f3f593b1a21 100644 --- a/core/api/test/unit/domain/wallets/payment-input-validator.spec.ts +++ b/core/api/test/unit/domain/wallets/payment-input-validator.spec.ts @@ -29,7 +29,6 @@ describe("PaymentInputValidator", () => { }, contactEnabled: true, contacts: [], - quiz: [], kratosUserId: "kratosUserId" as UserId, displayCurrency: UsdDisplayCurrency, }