diff --git a/src/machines/auth.ts b/src/machines/auth.ts index a4d931c..e8a23c0 100644 --- a/src/machines/auth.ts +++ b/src/machines/auth.ts @@ -1,10 +1,18 @@ import { createMachine, raise } from 'xstate'; -import { AccountStatus, activateOnUssd, blockOnUssd, updatePinAttempts } from '@db/models/account'; -import { BaseContext, BaseEvent, isOption00, MachineId, translate, updateErrorMessages } from '@machines/utils'; -import { ContextError, MachineError, SystemError } from '@lib/errors'; +import { AccountStatus } from '@db/models/account'; +import { ContextError, MachineError } from '@lib/errors'; import { PostgresDb } from '@fastify/postgres'; import { Redis as RedisClient } from 'ioredis'; -import { updateGraphUser } from '@lib/graph/user'; +import { + isOption00, + MachineEvent, + MachineId, + MachineInterface, + updateErrorMessages, + UserContext +} from '@machines/utils'; +import { AccountService } from '@services/account'; +import { translate } from '@i18n/translators'; const bcrypt = require('bcrypt'); @@ -14,8 +22,13 @@ enum AuthErrors { UNAUTHORIZED = "UNAUTHORIZED" } +export interface AuthContext extends UserContext { + data: { + initialPin?: string, + } +} -export const authMachine = createMachine({ +const stateMachine = createMachine({ id: MachineId.AUTH, initial: "authenticating", predictableActionArguments: true, @@ -116,28 +129,20 @@ export const authMachine = createMachine({ } }) -export async function activateAccount (context: BaseContext) { - const { resources: { db, graphql, p_redis }, user: { account: { phone_number }, graph: { user: { id } } } } = context - const initial = context.data?.pins?.initial - - if (!initial) { - throw new SystemError(`Malformed context data. Expected 'pins.initial' to be defined.`) +export async function activateAccount (context: AuthContext) { + const { connections: { db, graphql, redis }, user: { account: { phone_number }, graph: { user: { id } } } } = context + if(!context.data?.initialPin){ + throw new MachineError(ContextError.MALFORMED_CONTEXT, "Context data is missing initialPin."); } - - if(!id) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Graph user id missing from context object.") - } - - const hashedInput = await hashValue(initial) - await activateOnUssd(db, hashedInput, phone_number, p_redis) - await updateGraphUser(id, graphql, { activated: true }) + const hashedPin = await hashValue(context.data.initialPin) + await new AccountService(db, redis.persistent).activateOnUssd(graphql, id, phone_number, hashedPin) } export async function blockAccount (db: PostgresDb, phoneNumber: string, redis: RedisClient) { - await blockOnUssd(db, phoneNumber, redis) + await new AccountService(db, redis).block(phoneNumber) } -export async function hashValue(value: string) { +export async function hashValue(value: string){ try { return await bcrypt.hash(value, 8); } catch (err) { @@ -145,74 +150,67 @@ export async function hashValue(value: string) { } } -export function isBlocked (context: BaseContext) { +export function isBlocked (context: UserContext) { const { user: { account: { status } } } = context return status === AccountStatus.BLOCKED } -export function isPendingOnChain (context: BaseContext, _: any) { +export function isPendingOnChain (context: AuthContext) { const { user: { account: { activated_on_chain, status } } } = context return !activated_on_chain && status === AccountStatus.PENDING } -export function isPendingOnUssd (context: BaseContext) { +export function isPendingOnUssd (context: AuthContext) { const { user: { account: { activated_on_chain, activated_on_ussd, status } } } = context return ( status === AccountStatus.PENDING && activated_on_chain && !activated_on_ussd + || status === AccountStatus.RESETTING_PIN && !activated_on_ussd ) } -export function isValidPin (_: BaseContext, event: any) { +export function isValidPin (_: UserContext, event: any) { return /^\d{4}$/.test(event.input) } -export function pinsMatch (context: BaseContext, event: any) { - const initial = context.data?.pins?.initial - - if (!initial) { - throw new SystemError(`Malformed context data. Expected 'pins.initial' to be defined.`) - } - - return initial === event.input +export function pinsMatch (context: AuthContext, event: any) { + return context.data.initialPin === event.input } -export function savePin (context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - pins: { - ...(context.data?.pins || {}), - initial: event.input - } - } +export function savePin (context: AuthContext, event: any) { + context.data = { initialPin: event.input } return context } export async function updateAttempts(attempts: number, db: PostgresDb, phoneNumber: string, redis: RedisClient, status: AccountStatus) { if (status === AccountStatus.BLOCKED) return const updatedAttempts = attempts + 1 - await updatePinAttempts(db, phoneNumber, updatedAttempts, redis) + await new AccountService(db, redis).updatePinAttempts(phoneNumber, updatedAttempts) updatedAttempts === 3 && await blockAccount(db, phoneNumber, redis) } -export async function validatePin(context: BaseContext, input: string) { - const { user: { account: { pin, phone_number, pin_attempts, status } }, resources: { db, p_redis } } = context +export async function validatePin(context: UserContext, input: string) { + const { connections: { db, redis }, user: { account: { pin, phone_number, pin_attempts, status } } } = context const isValidPin = /^\d{4}$/.test(input) if (!isValidPin) { throw new MachineError(AuthErrors.INVALID, "Invalid pin format.") } const isMatch = await bcrypt.compare(input, pin) if (!isMatch) { - await updateAttempts(pin_attempts, db, phone_number, p_redis, status) + await updateAttempts(pin_attempts, db, phone_number, redis.persistent, status) throw new MachineError(AuthErrors.UNAUTHORIZED, "Unauthorized pin.") } - await updatePinAttempts(db, phone_number, 0, p_redis) + await new AccountService(db, redis.persistent).updatePinAttempts(phone_number, 0) } -export async function authTranslations(context: BaseContext, state: string, translator: any) { +async function authTranslations(context: AuthContext, state: string, translator: any){ if (state === "mainMenu"){ const { user: { vouchers: { active: { balance, symbol } } } } = context - return await translate(state, translator, { balance: balance, symbol: symbol }) - } else { - return await translate(state, translator) + return translate(state, translator, { balance: balance, symbol: symbol }) } + return translate(state, translator) +} + +export const authMachine: MachineInterface = { + stateMachine: stateMachine, + translate: authTranslations } \ No newline at end of file diff --git a/src/machines/balances.ts b/src/machines/balances.ts index c71097d..6d4131b 100644 --- a/src/machines/balances.ts +++ b/src/machines/balances.ts @@ -1,20 +1,19 @@ import { - BaseContext, - BaseEvent, isOption1, isOption2, isOption9, isSuccess, + MachineEvent, MachineId, - translate, - updateErrorMessages + MachineInterface, + updateErrorMessages, + UserContext } from '@machines/utils'; import { createMachine, raise } from 'xstate'; -import { retrieveWalletBalance } from '@lib/ussd/account'; -import { Cache } from '@utils/redis'; -import { Voucher } from '@lib/graph/voucher'; import { isBlocked, validatePin } from '@machines/auth'; import { MachineError, SystemError } from '@lib/errors'; +import { getSinkAddress, retrieveWalletBalance } from '@lib/ussd'; +import { translate } from '@i18n/translators'; enum BalancesError { @@ -22,7 +21,13 @@ enum BalancesError { LOAD_ERROR = "LOAD_ERROR", } -export const balancesMachine = createMachine({ +export interface BalancesContext extends UserContext { + data: { + communityBalance: number, + } +} + +export const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOnQFcAXbAewCdcAvAqAIXQBt19MwA1XGADuAQQDEEGoRIEAbjQDWYMlVoNm+Np268BwkQG0ADAF1EoAA41YuSrinmQAD0QAWAOwBGEp4BMrgE53dyMAZl9fIPcAVgAaEABPRHcANl8SfwCAzxSAoyMU0PcAX2L4tCw8QlIKanomFnYuHn5BUTEwOjp6EgsuSgAzelQVOvVG7Ra9UWMzJBArGzsHeZcED28-QOCwiKi4xMRPV1dvLKyU0+j3V1yADlLyjBwCYlG1Bs0mnVb9Dq6en10INhu96hotM1dG1DJ45pZrLZ7PhHGsNj5MjtwpFggckgg7ikUiRAgE7q5fClPHd3GSSmUQBUXtUweMvpNoX9Ot06L1+kM6CNah8Id8pjCDL54QtEcsUas3F4MdsQtj9vF8Z5PO47iTzkYdXcaXdor5HoznlU3sLwRMob8hABhCRSZRyRTKG1syE-aaO2aORZIlagNFKrZRXY4mIaxApaIBDLRfKeUIm7UU81Mq01VS29n2v3-Hl84ECoV571izlOgPzINy1GKzaY1V7XGxgneZP5Iw5W7+A0BLOW165safH3i4TO7mA-mgr2T6sO-1wwOy5FN9bh1tR9WHBDRY8kXv5AIJ1x3UKmkeVMes5cc1fF+dlxeVp+Ftr+qUbpZbgqO4tiq+4doeFKuKevYRISN4BFcd7Mm8ABGz6wAAsmA+DkGIrAiI6ADSdYIgBIbOEc5K6q4BRGKangIXcZK+J2fhBCQNxZEYQS3GSoSeEhOYkGh9qYdhuEACoAEoiAAcgAygAkhJJEymR8qhpRV4krR9GMcxnZ3H4p7XqEQ40seNyCQ+Ik-GJOFiNJclKSp671pu5FrNS2k0SkdG+AxV4GYeZneDEKapIEoSnNZLLYZQnQsAACgQ4j4URqkNoBmkIL4fbRKeAQ3q4xV+f4LGHoS6TRKZnjJn5+QmrFbzxYlmgpfg4hOQpymZR5GkUblrjRGcpo0nxRR3P4nbDUYPhEiEOoIaEVIpM1pCtQw7WpY5Mk9a50pZZ5lHBCQRQeEVgQ5FNoSsfk7gcYETEre44Rma460kJtyUEM66XEaY-7BgNax5XVhXFaVRjlZ2vgjUmKZ0akRpktEn3fdt+DOt1Ll9ep27+PDF6+ONpk6tNh4ROk4V9iaK1hMmH0MtmD4Y1AHXY3tuNuaRwPbtSp3nbS0XZCkN2w-kHE9gzRgnGm7hmszo4sgMYCUDg8nkJgvCwLAeEEQDh39fzoQISQAQBTE2y5NxKSdrSD2nES-GRMx8afar6vYJr2twHrOO9YD7n40BqZmxb2rRNbeQBHbh6XKE5s5ESRoMfxYufXInC4BAHXiFJACi0kAJp43zoem1BEdW0ENux6x2rEuceRpJ4BrXPSTz3iyWccDnHNiIXJdl42Ffh5bUe1zHcf4hS1UwVqJrRESUefRwNDoBAPs63r-0j9lg1h1XE-R7bhkeEnzvXgrvjBGtSvd286+b9vfu7c5gdGyHOVH+bJ9T2fQ8Nwm7J3KrfIo0RQilAZPgGgEA4COBZtUIGo8coAFoZ6IAwZ9LAmAaDkHwJQVg69MBKAgCgg+oMvBzQjgmPIdVY5TXPj4UkZItQW1lgUHBn5RTPmmCIChx0EB+QxMmY4k80xpgqviC8iZWF+FSDcOiw4H7IXHCKO0vofyCJBm4aRiAihhWlm3NuK0EKfVsi0ey5AdH8ypFBSIKQQjxhFhse2eQzqhHCEZW4yjobo0IW1dmqVbFAQCoEEgflsiQJCCYmMEESYkmTkZF6lc0aqKEmzDmoScrhMTFEuqRQ+x9nifiPKNDm7lJbg7dGThbA5MPvYjIsdnGQMCG4+OK1oL5FgkVAolwPZqxwAXAEdAGlrCYukCOdxoZ5GvCcdwnZIFzWlkUOGRUGICQyQ+T2GstY73GUcXYZ0ZlbFSLkCknhOyFAKs3E0lwnEeEVl3NRMh8CyGzrnEJwdy45UmX-aksyjDzI8A3RJpImIK24sNe+LyhK937r9Q5BILYApmZEYF0VQUhRuN0vsAVySUhGmvDeEARk8mRf86ZQKQWLMqqcc2WRaQZmGmsklL99l+2RW3bSlw6LJljseEqt0gGmzxcNYI-FwidwtI-UgsA1Z2E0NY5FeTIncUKbEkpeIji3ygpxQk-KDTtOgcUIAA */ id: MachineId.BALANCES, initial: "balancesMenu", @@ -162,7 +167,7 @@ export const balancesMachine = createMachine({ } }) -async function loadAccountBalance(context: BaseContext, event: any) { +async function loadAccountBalance(context: BalancesContext, event: any) { const { user: { vouchers: { active: { balance } } } } = context; const { input } = event; @@ -177,42 +182,38 @@ async function loadAccountBalance(context: BaseContext, event: any) { } } -async function fetchCommunityBalance(context: BaseContext, event: any) { - const { resources: { provider, p_redis }, user: { vouchers: { active: { address: contractAddress } } } } = context; +async function fetchCommunityBalance(context: BalancesContext, event: any) { + const { connections: { graphql, provider, redis }, user: { vouchers: { active: { address: contractAddress } } } } = context; const { input } = event; await validatePin(context, input) - const cache = new Cache(p_redis, contractAddress); - const voucher = await cache.getJSON(); + const sinkAddress = await getSinkAddress(contractAddress, graphql, redis.persistent) - if(!voucher) throw new SystemError("Voucher not found in cache.") + if(!sinkAddress) throw new SystemError(`Failed to fetch sink address for contract ${contractAddress}.`) try { - const balance = await retrieveWalletBalance(voucher.sink_address, contractAddress, provider); + const balance = await retrieveWalletBalance(sinkAddress, contractAddress, provider); return { balance, success: true } } catch (error) { throw new MachineError(BalancesError.FETCH_ERROR, "Failed to fetch community balance.") } } -function saveCommunityBalance(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - communityBalance: event.data.balance - } +function saveCommunityBalance(context: BalancesContext, event: any) { + context.data.communityBalance = event.data.balance; return context; } -function isFetchError(_: BaseContext, event: any) { +function isFetchError(_: any, event: any) { return event.data.code === BalancesError.FETCH_ERROR; } -function isLoadError(_: BaseContext, event: any) { +function isLoadError(_: any, event: any) { return event.data.code === BalancesError.LOAD_ERROR; } -export async function balancesTranslations(context: BaseContext, state: string, translator: any) { +async function balancesTranslations(context: BalancesContext, state: string, translator: any) { const { data, user: { vouchers: { active: { balance, symbol } } } } = context; switch (state) { case "loadSuccess": @@ -223,3 +224,8 @@ export async function balancesTranslations(context: BaseContext, state: string, return translate(state, translator); } } + +export const balancesMachine: MachineInterface = { + stateMachine, + translate: balancesTranslations +} diff --git a/src/machines/intermediate.ts b/src/machines/intermediate.ts deleted file mode 100644 index 4d47249..0000000 --- a/src/machines/intermediate.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - BaseContext, - BaseEvent, - isOption1, - isOption2, - isOption3, - isOption4, - isOption5, - MachineId, - translate -} from '@machines/utils'; -import { createMachine } from 'xstate'; - -export const mainMenuMachine = createMachine({ - id: MachineId.MAIN, - initial: "mainMenu", - states: { - help: { - type: 'final' - }, - mainMenu: { - on: { - TRANSIT: [ - { target: 'transfer', cond: 'isOption1' }, - { target: 'voucher', cond: 'isOption2' }, - { target: 'settings', cond: 'isOption3' }, - { target: 'help', cond: 'isOption4' } - ] - } - }, - settings: { - description: 'Transitions to profile machine', - type: 'final' - }, - transfer: { - description: 'Transitions to transfer machine', - type: 'final' - }, - voucher: { - description: 'Transitions to voucher machine', - type: 'final' - } - } -}, { - guards: { - isOption1, - isOption2, - isOption3, - isOption4 - } -}) - -export async function mainMenuTranslations(context: BaseContext, state: string, translator: any) { - const { user: { vouchers: { active: { balance, symbol } } } } = context - if (state === "mainMenu"){ - return await translate(state, translator, { balance: balance, symbol: symbol }) - } else { - return await translate(state, translator) - } -} - -export const settingsMachine = createMachine({ - id: MachineId.SETTINGS, - initial: "settingsMenu", - states: { - balances: { - description: 'Transitions to balances machine', - type: 'final' - }, - language: { - description: 'Transitions to language machine', - type: 'final' - }, - mainMenu: { - description: 'Transitions to main menu machine', - type: 'final' - }, - pinManagement: { - description: 'Transitions to pin management machine', - type: 'final' - }, - profile: { - description: 'Transitions to profile machine', - type: 'final' - }, - settingsMenu: { - description: 'Displays account management menu.', - on: { - BACK: 'mainMenu', - TRANSIT: [ - { target: 'profile', cond: 'isOption1' }, - { target: 'language', cond: 'isOption2' }, - { target: 'balances', cond: 'isOption3' }, - { target: 'statement', cond: 'isOption4' }, - { target: 'pinManagement', cond: 'isOption5' } - ] - } - }, - statement: { - description: 'Transitions to statement machine', - type: 'final' - } - } -},{ - guards: { - isOption1, - isOption2, - isOption3, - isOption4, - isOption5 - } -}) - -export async function settingsTranslations(context: BaseContext, state: string, translator: any) { - const { user: { vouchers: { active: { balance, symbol } } } } = context - if (state === "mainMenu"){ - return await translate(state, translator, { balance: balance, symbol: symbol }) - } else { - return await translate(state, translator) - } -} - diff --git a/src/machines/languages.ts b/src/machines/languages.ts index ebdc1d3..ac89f84 100644 --- a/src/machines/languages.ts +++ b/src/machines/languages.ts @@ -1,29 +1,35 @@ import { BaseContext, - BaseEvent, - getLanguage, isOption00, isOption11, isOption22, isOption9, isSuccess, - languageOptions, + MachineEvent, MachineId, - translate, - updateErrorMessages + MachineInterface, + updateErrorMessages, + UserContext } from '@machines/utils'; import { createMachine, raise } from 'xstate'; -import { supportedLanguages } from '@lib/ussd/utils'; import { isBlocked, validatePin } from '@machines/auth'; import { ContextError, MachineError } from '@lib/errors'; -import { updateLanguage } from '@db/models/account'; +import { Locales } from '@i18n/i18n-types'; +import { AccountService } from '@services/account'; +import { getLanguage, languageOptions, supportedLanguages, translate } from '@i18n/translators'; enum LanguageError { CHANGE_ERROR = "CHANGE_ERROR" } +export interface LanguagesContext extends UserContext { + data: { + selectedLanguage: Locales, + } +} + -export const languagesMachine = createMachine({ +const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOgDNcAnWAFwBl18oBXdGAZTBoGIAhAQQDCAaQDaABgC6iUAAcA9rFw1c8-DJAAPRAEYALHvEkA7AFZx4vQGZxVgBx6AnI70AaEAE9EAJgBsdklM7KxCdKx07HUdg3wBfWPc0LDxCUgpqekYWNjBOHgAVACV+ADl2AEl8iWkkEAUlFTUNbQR9QxNzSxt7Jxd3LwRHK2MSHV9x40mo6zH4xIwcAmJyKloGJlYOLm4i0oqqnRq5RWVVdVqWtqMzC2tbB2c3T0RhvRJxYKi7R18b39M5iAkotUisMutslsCsUypVRN4jnUTo1zqBLgZrp07j1Hv1dN4bCRvI4dMZxDpvHYJh9AcCUstYGBMGoIBDNrltrtYVUpBp6qcmhdEHpjFZ3hYLFZib9vuM8QhjEMSL4rKY9N5iXpPr9aQt6aRGcz8Kysuy8jsYftRIc+cizs1haLxRKpT9jLLfPKdDpTKYSFZrJM7MZKdM9LrkksDUyWWycuauVaEbaGvahQgRWKJZLpe6fp7noMyaNfBqxqZvOJTNFHBGQQyY8a41CLXs4VZEfyUQ6M07s7Zcx6vYYdIE9KTLL4tWZTFY6-qSDQ8JQTRt45zLXDebUu2m0boMR1bt0Hn1CzoPmKQyTvuYbKYKfOo4vl6vIRzoW2Dp27YL9wgVUcf1HHEXxxEVYIQLsUwvQvN4HGGYwpypOxLABBIgT1Z8lyoN8zQ3L94R-VM-y0A92huLp7l6J4BgpVCTBJMlVUpUCqyfUECAAN3QAAbXA8JyAB5WQUW4QoAFEigATWqFMBVRMjWinMVemcaDRT0O9YMVRixmcNDQx0DjljAfAaDASgCCgAAFAg+CEMRt2OEjFMuXxzBIQNzDVMDMwLOjbDFDUbxDXwxnC9D5kjUEzIsqymDs-BW25OSd1-NyfD8AJ3TCC9vGsKcAsQOwCS8iLInHYx1RFEzSDiyzrKSlKrRtdLXJ7H0KVGcQXF60sqrCeURQCEkJl9cRK0cd04gwuln3QZgl3kKyAC9rObMBBGwLIwG4CA1DAEhuPkABrI7FuWtaNtNHJtt2tKXIUntM2dHM3SHQsCVUiLzB9e87GDOqSEu7AVtwdamE2+6mD2yzKBWkhZF49AaDIFbUBBpaweuqHbpgGGYEepEOvTV7+1dGV83lECSDG3wmM0sIPOB0HwchqBoZ22HuHhxHkdR9HKExtncc5-Gtu5om2qe7syb7bNKbzOVCynIDxgmJCJg01nsfZm61wJqW4coBHKCRlG0YxrGrohg330JsAiPkuX-3JxXB2pwsQhGDWp0pbxfWg7xge4viBOaySZOJ3dSPRCisRPGj5WQksJjA8wqrnOasNBHBdvYZhMEwOBYAckQY4yzrD0o7FT1onwIneYJXXA-wPmMYH89hwvi9Llqt2I570yuI8qJxM8BilMUgnCN0-G9YxjMBfB5AgOANHm1IXb3JSAFpioQPe-WcdSgiXjzfSsWsc5i5Z0jWCW8h3uPdEnf1TCX3129VFUaa1cqGpvA+ipKBEIwNDSxiflwF+mUAIBEnFSTO0FSqinlKVMUEUNYiiCH4cMt96ykBwiuTaz92rD3-NEP01ZELAK6JWYwXpeoBBoTOCkk1vghwIQuMO-FBIwBEq7EmFClKkiQiYSI3h3TQUsAYQ+3poh0yquSH4wZl7RUISQBqCVbIEFgZ1VCfpJogQmnQiwjh0HkmVNgwG4EbDjl1rbDmXNdr6LJt4dBIwQjeMmFWLSjguEaJ4fgHifCkpuMoU3Si3opwVmmjBQs1Z1Yay1hqRUUjWbF3kMwcyvBeLyEwOdCAESlKBw+KMUwpYhjhW9NWBJAxIiBGzKSJwPo-CBMwnfUg3cYASVNitEpLQbxjxiVpYkZh5ThEwRrD4thmLsW4c+HpuQi4l1gPAchQjLi9WuNfGpyFwgGHlHQtODMKSTGqpKYGYBNDKEGboSYvgJHnMBpnORkyfTWI8peCIGotL4KCc+RkNAVBMFgAAWTMswe5rRHnPKka82RehD4nIitVb0wx9DZ3iEAA */ id: MachineId.LANGUAGES, initial: "firstLanguageSet", @@ -43,8 +49,8 @@ export const languagesMachine = createMachine({ onDone: { target: 'changeSuccess', cond: 'isSuccess' }, onError: [ { target: 'accountBlocked', cond: 'isBlocked', actions: 'updateErrorMessages' }, - { target: 'invalidPin', cond: 'isInvalidPin', actions: 'updateErrorMessages' }, - { target: 'changeError', cond: 'isChangeError', actions: 'updateErrorMessages' } + { target: 'changeError', cond: 'isChangeError', actions: 'updateErrorMessages' }, + { target: 'invalidPin', actions: 'updateErrorMessages' }, ] }, tags: 'invoked' @@ -149,45 +155,38 @@ export const languagesMachine = createMachine({ } }) -async function initiateLanguageChange(context: BaseContext, event: any) { - const { data: { languages }, resources: { db, p_redis }, user: { account: { phone_number } } } = context +async function initiateLanguageChange(context: LanguagesContext, event: any) { + const { connections: { db, redis }, user: { account: { phone_number } }, data } = context const { input } = event await validatePin(context, input) - if (!languages) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Language selection is missing from context.") + if(!data?.selectedLanguage){ + throw new MachineError(ContextError.MALFORMED_CONTEXT, 'Selected language is not set.') } try { - await updateLanguage(db, phone_number, languages.selected, p_redis) + await new AccountService(db, redis.persistent).updateLanguage(data.selectedLanguage, phone_number) return { success: true } - } catch (error) { - throw new MachineError(LanguageError.CHANGE_ERROR, "Language change failed.") + } catch (error: any) { + throw new MachineError(LanguageError.CHANGE_ERROR, `${error.message} - ${error.stack}`) } } -function isValidLanguageOption(context: BaseContext) { - return Object.keys(supportedLanguages).includes(context.ussd.input) +function isValidLanguageOption(context: LanguagesContext, event: any) { + return Object.keys(supportedLanguages).includes(getLanguage(event.input)) } -function saveLanguageSelection(context: BaseContext, event: any) { - const { input } = event - context.data = { - ...(context.data || {}), - languages: { - selected: getLanguage(input) - } - } +function saveLanguageSelection(context: LanguagesContext, event: any) { + context.data.selectedLanguage = getLanguage(event.input) return context } - -function isChangeError(context: BaseContext, event: any) { - return event.data.code === LanguageError.CHANGE_ERROR +function isChangeError(context: LanguagesContext, event: any) { + return event.data.code === LanguageError.CHANGE_ERROR || event.data.code === ContextError.MALFORMED_CONTEXT } -export async function languagesTranslations(context: BaseContext, state: string, translator: any){ +async function languagesTranslations(context: BaseContext, state: string, translator: any){ const languages = await languageOptions() switch(state) { case "firstLanguageSet": @@ -199,4 +198,9 @@ export async function languagesTranslations(context: BaseContext, state: string, default: return await translate(state, translator) } +} + +export const languagesMachine: MachineInterface = { + stateMachine, + translate: languagesTranslations } \ No newline at end of file diff --git a/src/machines/main.ts b/src/machines/main.ts new file mode 100644 index 0000000..2e431e5 --- /dev/null +++ b/src/machines/main.ts @@ -0,0 +1,56 @@ +import { + intermediateMachineTranslations, + isOption1, + isOption2, + isOption3, + isOption4, + MachineEvent, + MachineId, + MachineInterface, + UserContext +} from '@machines/utils'; +import { createMachine } from 'xstate'; + +const stateMachine = createMachine({ + id: MachineId.MAIN, + initial: "mainMenu", + states: { + help: { + type: 'final' + }, + mainMenu: { + on: { + TRANSIT: [ + { target: 'transfer', cond: 'isOption1' }, + { target: 'voucher', cond: 'isOption2' }, + { target: 'settings', cond: 'isOption3' }, + { target: 'help', cond: 'isOption4' } + ] + } + }, + settings: { + description: 'Transitions to profile machine', + type: 'final' + }, + transfer: { + description: 'Transitions to transfer machine', + type: 'final' + }, + voucher: { + description: 'Transitions to voucher machine', + type: 'final' + } + } +}, { + guards: { + isOption1, + isOption2, + isOption3, + isOption4 + } +}) + +export const mainMachine: MachineInterface = { + stateMachine, + translate: intermediateMachineTranslations +} \ No newline at end of file diff --git a/src/machines/pins.ts b/src/machines/pins.ts index 99071d9..a021cb8 100644 --- a/src/machines/pins.ts +++ b/src/machines/pins.ts @@ -1,21 +1,21 @@ import { - BaseContext, - BaseEvent, isOption1, isOption2, isOption3, isOption9, isSuccess, isValidPhoneNumber, + MachineEvent, MachineId, - translate, - updateErrorMessages, - validateTargetUser + MachineInterface, + updateErrorMessages } from '@machines/utils'; import { createMachine, raise } from 'xstate'; -import { hashValue, isBlocked, isValidPin, pinsMatch, validatePin } from '@machines/auth'; -import { resetAccount, updatePin } from '@db/models/account'; +import { AuthContext, hashValue, isBlocked, isValidPin, pinsMatch, validatePin } from '@machines/auth'; import { ContextError, MachineError } from '@lib/errors'; +import { AccountService } from '@services/account'; +import { translate } from '@i18n/translators'; +import { validatePhoneNumber } from '@lib/ussd'; enum PinsError { @@ -24,9 +24,17 @@ enum PinsError { WARD_RESET_ERROR = "WARD_RESET_ERROR", } -export const pinManagementMachine = createMachine({ +export interface PinManagementContext extends AuthContext { + data: { + initialPin: string, + wardEntry: string, + validatedWardEntry: string, + } +} + +const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOnQFcAXbAewCdcAvAqABQIGFt18YBiCDUIkCANxoBrMGSq0GzXu3xceMANoAGALqJQABxqxclXEN0gAHogCMAJmsBmErYcAWDQHYArK68OAbNYAHA4ANCAAnoiu1gCcJBqJGr6xDkHWGtaurgC+OeFoWHiEpBTU9EwsSiq8YHxgdHT0JHoANuiUAGb0qDLl8lWc3LWaOkggBkYmZuNWCHaOzm6ePn6BIeFRCOkkXknW1h6HXn7WXnkFGDgExH1ylYpDqnUNTXQt7V09dxUKbE8jaxjfSGYymfDmOYLJwudzeXwBYJhSKIBxHXZJXxBVy2IIaByxC4gQrXEo-Aa8ADq6DoEAASnAwJQBEJpGJJNIyvc-tTaQzYEzRuZJmCZqAofYYct4WskZsbK43CRFQdDrYPOqHHYiSTircub8WLz6Yzma9mm0Ot06L0DRSoMb+YLtMLQdMIbMbJKlnDVoiNiiELZbP5XCQ8VkNEF-LFkkdcvliVc9aVZIaqTSTQKzY0LZ9rba0-bHaa1EDXVNwZCvYtYSsEetkVtrP5ErsPK5Wx4gh2HF4PA4dcmbqn+g8HZmnTm3h8rd87eOS9m1LZgRM3VXPfNvXWZf6m4hbBpYv5dl5HK5YrGvEeAkOiiOSJghJ1cDaAHJgADuSj4ACEAEEOAAaSFcYRXdasEEVeIPHxRICRcG9LwPeYtScfx0gODRg0VI5CUTXVH2ffBXw-b9fwAFTpAD3wAZQASUosCQUrMVLGiAkSDghwENiJDbBQ+UEG7JwgiwjVg2STsE0uB8yRIsjUE-H8CD4ajaMY5jy3Ajd2LmGDuPg-F+L7QTUmEjIDhINIMgHQTkhjQdCOHBSXzfZSKLUjT6KYlc1wgzdxU42DjMQsyhMDM5jwSLJsm8I9O3Ve9SVuMB8EoBoWBU39AJAlj1zYj1gqDLJT2vPFzwHBw0ksxxbBIRw7L2ZJj1SFKUxIdLMoYXgcu8mjfOYl1dKKqDI1PTxu1wnw3E7Or+JIVIDkErVL1sWIgg6x9uqyvqvPwdTBq0ssAr04qONKzslo0LxKsOGraqi4IGv8LUmrRaMb2cuTUtIXbeqgfrDp8k7VwrUULrmexroq26HpqoJLNxDCtRWTJQ1vbayQBlgAHlWggXKgNAkbWMhqCHHseIgju2IvHp2JL1iFthLg+IWt42NgxjTtsbSjK9qgAmiYGzS-LJwqKa3Pt6e44JzxvYNNo8YSXA0JaW38c8Ag2jso35-7BcBkWqOOvydPJyCZajBrI0VE44jegc1bbbxEmDGrO1bc4XPkgWesGfBKTpf8SYKwL9NRW6Go8E9+0VLUNtsLxLOjDWrzsE5eLj3FZKTf2jcDx5g9D0GJbOsabZvbj497JP+NTqKWesZUWeDI4Q3SQTDa642g5Do7xe0yvpZK3Cgl2fwkIyNEdab5tG-DTJ7EyGrMhOXvcYzWkw-yyXI6h1FqfDOmGYZ5nWcDK8GrutxsUSG9xK3-ud4gIehoj87KbiLwluxM4sZaYp1VlFBmGs3DdhONrDw08foFz+iIfAoh0CtFwBAYGfA6QAFFqIAE0v5V3HmieIGR+KZFcHicSyMaqNXeviOI3gAjwKImSMQqD0GmzUjg-BhCx6XV4jXOOMDE52EbsJNEGskiZA8DxQ4XZe7sLQaLUuWDcF0gIQfb+W4YblVuvdaqT0thHjlvDGqLg+z4lcB4XuegAQwDouQTAmA4CwD3qTUe1tx5lRundeGhikaBndrsaxLNvZMwHPnVhtw7HKGGA4pxLjYBuPLsNTxQVLq6N8QYx6gStjYgamtXigQNq-xYa5GJBAACyPB0AwFQN1Kp6VyDuL4V4gRMda4iJqmIlObNWwJA9lQmI-Zoy2OqbU+pjTmkfxOloohHShF11EcnBeiBtbxGkSENYeJcTjPwDU-AdSwANIyk0-ALTUmnQhu0uYgjY7LJ6as4STMGpRllv4DsR4Bz7MOcc05lBzmXPNsxcGo1+F3M6cIhOTzxGBi+hiRIm0vDrB8LYX5uBYAYEoDgNRvD5kQsQAzDmnY4IhnxEeFFlksS7CyASA4KRjw2L9og8gegIAdCDiyYQ7IpAkDZRykwJc2kZIlLWaUfpGz9L-uJOwBI4IdlSOUwu-L2WcpLvUXM7xLRfBtKqwVQcRVR23OK30DY5TwrgnQg4JwbyYR8L3FByj1UTlpJRGgU5uVsmQRyEgTr0EuuNO6qcRqj5XT0X4qquS05U3DKEvYHY7DT38I6jhBq37BtNJqmcOqCx+rTYGzMmblwEtuYeHxcMo2I0skcVuTKTwP1uphFNLLOpfknKaRxzjXGtNLaK8tsN9H+OjVFGImzYhQJReeJKLbfpto7dmLtSSUkgtDVBLJlaEZGIVDGXYE6vlhM+bAvIiZ8A0AgHAcw0SiA3P7QgAAtP4YSj7e5YGfOQDKf5Wg0EwFICAt7jVpB8CQTCA45UDjglkYS2JGotW+e4E4OJmVzsfAuP41R4lgAA2G3ZSwCTZyqrnSy2sQPSJcOkaeDbX1FkXAupk2GoK4YsfTPwhH+Jp1kWeRwARm3dg1L3RSHlgYMa3DiOq3Zwz0zSFeAcrZNov2LkDA6ImSrBDOLFDaex-AxjpuJ1uamsiIY2tiBTQsuEXUPpTXWzh6pXgZsEWmrtSHac+SnFmOETOtp2q-f4pcVOZI2eGI4gQGEUdumnPwCQOwMzxCebIN5TOA2NP5u5fYwxomsRqQIapUJM0nkkRKGoNCtm1lvCwxgUuHh8BrYRmFu6MwndB66MkcTRTukiRRyC03CfBWWoM1Wul1bEcS0BzYTEJEy8U2Bnh0idf9RAczlX+vuEG7KzTMmJH2ERYkIIgkYg4V8HNtNSgQ5LZbNkXxLMggTvO2soMjkuNU1xN4E81h9k1BgNgrVZ3QxhmvFdm7oY7vYj-m9DIsqNSUPPO9zDS7XFnZCPEK8E6ows1WGcNmOIQNJ0TtrT6s6EGdViX8qZZzmlLfVFt3OgR278VkaNxAvZlRA9gTGHsBwCIobJMTzF2KcBLdrT6YIjc4gp1QnSsMKoDgahl0nXu2YhVQFgECpbNVgMZdkSGaXLg2ZokGThNG-YSHKsQbAH9uBUEMmfKIBoEQVe9bvWr9LHZNfZY1KhApiL7DdnEjfTnhPHwCpdUoJbu2Na+HVLGT5exDOY7-k1QBQCrIE+vfm51iug0etNGd+ME23p+EvFGXbrhiM7ATuA2B2zFS93bXyU0X23g-Yu-94XwXfCWX7JNDsrYCQBH1shgPZJa9ZiZHD5JCOVsRhd9PHmHZqXWQM9iSBAQtonqAA */ - id: MachineId.PINS, + id: MachineId.PIN_MANAGEMENT, initial: "pinManagementMenu", predictableActionArguments: true, states: { @@ -245,98 +253,93 @@ export const pinManagementMachine = createMachine({ } }) -function isChangeError(context: BaseContext, event: any) { +function isChangeError(context: PinManagementContext, event: any) { return event.data.code === PinsError.CHANGE_ERROR } -function isWardResetError(context: BaseContext, event: any) { +function isWardResetError(context: PinManagementContext, event: any) { return event.data.code === PinsError.WARD_RESET_ERROR } -function savePin(context: BaseContext, event: any) { +function savePin(context: PinManagementContext, event: any) { context.data = { ...(context.data || {}), - pins: { - ...(context.data?.pins || {}), - initial: event.input - } + initialPin: event.input } return context } -function saveValidatedWard(context: BaseContext, event: any) { +function saveValidatedWard(context: PinManagementContext, event: any) { context.data = { ...(context.data || {}), - pins: { - ...(context.data?.pins || {}), - wards: { - ...(context.data?.pins?.wards || {}), - validated: event.data - } - } + validatedWardEntry: event.data.ward } return context } -async function authorizePinChange(context: BaseContext, event: any) { +async function authorizePinChange(context: PinManagementContext, event: any) { await validatePin(context, event.input) return { success: true } } -async function initiatePinUpdate(context: BaseContext) { - const { data: { pins }, resources: { db, p_redis }, user: { account: { phone_number } } } = context; +async function initiatePinUpdate(context: PinManagementContext) { + const { connections: { db, redis: { persistent } }, data: { initialPin }, user: { account: { phone_number } } } = context; - if (!pins?.initial) { + if (!initialPin) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Initial PIN missing from context.") } - const hashedPin = await hashValue(pins.initial) + const hashedPin = await hashValue(initialPin) try { - await updatePin(db, hashedPin, phone_number, p_redis); + await new AccountService(db, persistent).updatePin(phone_number, hashedPin) return { success: true } } catch (error) { throw new MachineError(PinsError.CHANGE_ERROR, "Failed to update PIN.") } } -async function initiateWardPinReset(context: BaseContext, event: any) { - const { data: { pins }, resources: { db, p_redis } } = context; +async function initiateWardPinReset(context: PinManagementContext, event: any) { + const { connections: { db, redis: { persistent } }, data: { validatedWardEntry } } = context; const { input } = event await validatePin(context, input) - if(!pins?.wards?.validated) { + if(!validatedWardEntry) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Validated ward missing from context.") } try { - await resetAccount(db, pins.wards.validated, p_redis); + await new AccountService(db, persistent).reset(validatedWardEntry) return { success: true } } catch (error) { throw new MachineError(PinsError.WARD_RESET_ERROR, "Failed to reset ward's PIN.") } } -async function validateWardToReset(context: BaseContext, event: any) { +async function validateWardToReset(context: PinManagementContext, event: any) { const { user: { account: { phone_number } } } = context; const { input } = event - const ward = await validateTargetUser(context, input); - const { account: wardAccount } = ward; - const guardians = wardAccount.guardians || [] - if (!guardians.includes(phone_number)) { + const wardPhoneNumber = validatePhoneNumber(context.ussd.countryCode, input) + const guardian = await new AccountService(context.connections.db, context.connections.redis.persistent).getGuardian(wardPhoneNumber, phone_number) + if (!guardian) { throw new MachineError(PinsError.UNAUTHORIZED_GUARDIAN, "You are not a guardian of this account") } - return { success: true, ward: wardAccount.phone_number } + return { success: true, ward: wardPhoneNumber } } -export async function pinsTranslations(context: BaseContext, state: string, translator: any) { - const { data: { pins } } = context; +export async function pinsTranslations(context: PinManagementContext, state: string, translator: any) { + const { data: { validatedWardEntry, wardEntry } } = context; if (state === "wardResetSuccess" ) { - return await translate(state, translator, { ward: pins?.wards?.validated }) + return await translate(state, translator, { ward: validatedWardEntry }) } if (state === "wardResetError") { - return await translate(state, translator, { ward: pins?.wards?.entry }) + return await translate(state, translator, { ward: wardEntry }) } return await translate(state, translator) +} + +export const pinManagementMachine: MachineInterface = { + stateMachine, + translate: pinsTranslations } \ No newline at end of file diff --git a/src/machines/profile.ts b/src/machines/profile.ts index 3e2d5da..2223e33 100644 --- a/src/machines/profile.ts +++ b/src/machines/profile.ts @@ -1,6 +1,4 @@ import { - BaseContext, - BaseEvent, isOption1, isOption2, isOption3, @@ -9,24 +7,40 @@ import { isOption6, isOption9, isSuccess, + MachineEvent, MachineId, - translate, - updateErrorMessages + MachineInterface, + updateErrorMessages, + UserContext } from '@machines/utils'; import { createMachine, raise } from 'xstate'; -import { Gender, PersonalInformation, upsertPersonalInformation } from '@lib/graph/user'; import { isBlocked, validatePin } from '@machines/auth'; import { MachineError } from '@lib/errors'; -import { tHelpers } from '@i18n/translators'; -import { upsertMarketplace } from '@lib/graph/marketplace'; +import { tHelpers, translate } from '@i18n/translators'; +import { + createGraphMarketplace, + createGraphPersonalInformation, + Gender, + GraphMarketplace, + PersonalInformation, + updateGraphMarketplace, + updateGraphPersonalInformation +} from '@lib/graph/user'; enum ProfileError { CHANGE_ERROR = "CHANGE_ERROR", LOAD_ERROR = "LOAD_ERROR", } +export interface ProfileContext extends UserContext { + data: { + marketplaceName: string, + personalInformation: Partial + } +} + -export const profileMachine = createMachine({ +export const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOnQFcAXbAewCdcAvAqABTpoDNcAbMAYWzp8MAMQQahEgQBuNANZgyVWg2Yj2XXgKEiwAbQAMAXUSgADjVi5KuSWZAAPRACYAzAA43JAOwBOHwBGQwAWADYXcLcXABoQAE9EDz9AkjdDPxCAVkM3QL8skMCwgF8SuLQsPEJSCmp6JhZNbj5BYTEwOg46EnMedEpOelRlerUmjhaddoMTB0trW3skJ1dPb38g0Iio2ITEEJ8fNKyw9KyfQyOwww8QsoqMHAJiUdVGjUntNr1RTu7ev1BsM3g11Gwvq1dDB9IFTCsFjY7PgHM4EO4vL4AsFwpEzntEggfGcSLcXB4wltAvkUg8QJVnjVQeNPlooTM-l16ICBkM6CM6u9wc1vtCDC54RYrEjlqA0RiNtjtnjonFCX4NSQsh5yX5DFdPIVAnSGdVXoKwRM2WAAGq4MAAd3EkiUsgUSgtLIh1rtjqMkpAiKWKJW8vWWK2uN2asQbiKJBcKTCBTubhuLlK5XpTzNtRUltZU19Tv+3L6vJBno+3qL9od-vm0uDqLWmM2OJ2+JjCE8HixgUuhkTNz1hRNOZeebG1ZFfGLnIB5eB-OZM8htrrsIDQeRLfR4fbyuj+3RgW8hjCWUC5MMhS8eo846qk9XwvX89LPSXfIF+a9s43P0JUbRZd1DRBAhCHUtX1C9-DjNNAg8bsXFyXwcn1LJ1hyHwXCfRlXjAfBKE6FgADF0FQXh4gAOUosBRAAIQAQX4ABpBsESbMC5UQSk-FJWD0huHw8kMQJuyvLJBP1EI3ByOTk0zR5nyZIiSIYEQKKonhaPo0QABUACVmJogBlABJAzOKlUDZVWBB+Jk3ILwNYIJJPM88i1DUzjuB9kx8fDcxIdTSK0yjqLo1AGOM0zLOsuEQJlENeMc-xnOEtzxO7DxUhCDVCvSNwH3cYKXzCzSoG0qL9Li8yrP0YCuLs1KHKc2CXJEsSPMJMI7gTDDTlCJC-DCe4s1NCriPCqAAHFcBkIjorgJjWI4uYWpSvcBzcY5yUgpCPGO28XB8btqU8LUskyZIXDPFwysmic1JmqqFqW-AVtgQyTIa6zNts7bwIQQJtWObJojG47Impc6Tx1ASjnktw4zOPaInK16NJYD7lvon76oSrdkubEGwc1U4MyOSCNTyLJu0vPtikpJDtVGzHntUwi3pYAAZGhMAGZE1vYmzA24+z5SCFwSD8DH+r8Yd5fhwl7uidDTi8B7bxCCaVII0hKv5wXhckX74sawGJdavdUfyEhIfk8IwgiUS3BQ-U+3SM6XG1UIck8LGeZxkQBaF4MLf+kmtrJtL7YEp3Cldt3UZQsGQk1vKMyg8kzuDo3ecLUUZlYAhRY27dJbatE4w8aTs-rq5kzTD3PKTUksl9zwjh1ZTs25wvQ5rEu9DL-Ao+J62dyl2NwlSFXRJ8PKaQZk9xsMa7Mj2gpkc5g2QuN4u5zrceK-Fmea6SG45dTHV8l7v3uz27wMPEm6wZutMC9CouR5Px0Z8iZWyrrbcmucYL6n4ghMISFn6iUge-FIWQv79ymtjWaABNAA8oxc+09q47SXgmPKRRjonS7qrCClJM4oLOp-IIfgvCPi5obX+w8cF4OAQDUBwM0oKgjB2FUBJXC5DCCQDwQ5cKSPSD4Q4+sB5sKPlAThk9GpJVjjxByAjDxRi7J5Aq3h+o3DRqhSGWQf7KNUdwpqvC47aIPEqPRqpPL9UzvXehkjcg6jcD-WQ6AeC4AgHNIiEBOjYPMJHIyABRYymCL6EPJqJTewQAg5GTEOckyFPJQ18BmAcfsxo3SegfF8-jAkQHDmbfA0TiJ0HiKIGJcSElgPjocPsG9+owxyCI08oRBpawyLePKfj8AyACUElajTYlGXiQQ1pDlILQTftAxScCTyXA6a7XCXTkbHVGeMip49WD8Gmc0+ZfCHIJ0dlhZ2Kczpp08kOTekjboFTylhfZrCQrlKCccm0ZzZktMuWiDqQlXKiXcs-dwzlm4ZB8FeUIByJkQE4bUyg9TAVzLsVo6Wt5fCu1gpCjwVDQZnEzvJM6GRApMNyD-cw64ACyRFyD4JxbPdKAlOpZUhTlTyWFUiSN9u4fwdM0EvVeAy60zL8CspsRc+xoKMrcohT1C64ljivKguklySF6VMpZWoxK7Kr6csyqqqFBi8ppCkZBK4RQrz6ulYamxzUgaKr4sq8F3VLWEgHAUG19CAiRD1FBJ1UwZVyr+sTNwJq9xgv1DytVJ5HpYRguDSIJUla3nDdoSNRr9AhDjSDBNXVsq9QOONRB8l8geMuL475L5YBgD4JgWwIgQn4DCXQNlpNcXX03kwuS98AjLyfgYs6116GXCyRePCjamTNtbe2+aoTOgFoVf20GECVnwTWdkv1clN47NGqhHURwFHoNeEusAbbcZrp7TYjR7qt1LNlru+W+6LqjU7pkNJ+QLynB-jeu9HaH0FrdTbEFEEd2wVWYhA9EFPCb0erA15Q45IFTKFmfANAwnwBWFeogfaOUAFowjdnI7CzqnU5I-ywJgGg5BiKMR4ILRQEASOmoOhDW5kRhljTboScIm8CoFAKpeaIqGG2lKZFWN81ofgwC4ztAostNgXjTEOAIQnEBK3ESVRMZ47gpxCPO2T5o-xrh9HWFT5MKSpA03IjVUmUJ6kdizOmI1XYBB-jgGY0SuR0Ds-wvKvHUb8e1IJ7sxJUjpEyGZ6kgGEU-wgLgWA5Z4hWimCF7RYWbkRZCAJs4z9ij9kVnrc9y9LF-xqrpFauXa6Jl8EhI4zy5EoJKzk-pzcwZyMyDkPwNXh54y+gTRrEEHMRntS5x6uUYWGYKNecI2pDjDdmlU+xl89zDkzgNrC4RtYmfTtSQSFw0x3CK-LCxC6Q6zQAkpsA48Js9lgZvaIOobpDiwrhNefrijHHi1BJbZ1khBVu0Pe775T4EBe+5WWKQivuzBzqEIz8rzoX1O4VGx0xqXolZDqqnC4fZtJKJAIY1ULiSYRdS8sshWXARZeRn4rB7SDGSizt3aIlbcSWlJCsDpvOd2nN7rAlXk5AB7W3CyKKmbeROi+pL3ELvbzl9v2e1x1q1uBSkqVxigai7rLyZ9EXtFOraJKlV5EOOXkpj0IxILiJgyMbiAxz+AvZ4wV6IRWotdfVLhG1mRHdjXlnkV3-yXtmZQjkUkXgPs7x9q7V3aK6mEhfRyr3TtIvJH99QyCjstM93dh1n+bH0AQEC90T3+Xs++9z7p08qQjP1ovGZtJub2R6DMuQTAmA4AEYz9x2vfH6-RYRq7CRGQzP+SiNViHvQDWyuV95e6JLcJniKI8tW+Rdf0JQbkS4xoF8gZXVzzoZuEVpHvLha7VK-AXV91P+-mSsJ0pP2ASgK7YCRrN5eC30Qlw1uF0OQfYDOqMcYGQkQLCZQQAA */ id: MachineId.PROFILE, initial: 'profileMenu', @@ -103,7 +117,7 @@ export const profileMachine = createMachine({ on: { BACK: 'profileMenu', TRANSIT: [ - { target: 'enteringMarketplace', cond: 'marketplaceAbsent', actions: 'saveLocation' }, + { target: 'enteringMarketplace', cond: 'marketplaceNameAbsent', actions: 'saveLocation' }, { target: 'enteringProfileChangePin', cond: 'isValidLocation', actions: 'saveLocation' }, { target: 'invalidLocationEntry' } ] @@ -114,7 +128,7 @@ export const profileMachine = createMachine({ on: { BACK: 'profileMenu', TRANSIT: [ - { target: 'enteringProfileChangePin', cond: 'isValidMarketplace', actions: 'saveMarketPlace' }, + { target: 'enteringProfileChangePin', cond: 'isValidMarketplaceName', actions: 'saveMarketplaceName' }, { target: 'invalidMarketplaceEntry' } ] } @@ -249,7 +263,7 @@ export const profileMachine = createMachine({ saveGender, saveGivenNames, saveLocation, - saveMarketPlace, + saveMarketplaceName, saveYOB, updateErrorMessages }, @@ -269,11 +283,11 @@ export const profileMachine = createMachine({ isSuccess, isValidGender, isValidLocation, - isValidMarketplace, + isValidMarketplaceName, isValidName, isValidYOB, locationAbsent, - marketplaceAbsent + marketplaceNameAbsent }, services: { initiateProfileChange, @@ -281,168 +295,185 @@ export const profileMachine = createMachine({ } }); -async function initiateProfileChange(context: BaseContext, event: any) { - const { resources: { graphql, p_redis }, user: { account: { address, phone_number }, graph: { account: { id: graphAccountId }, user: { id: graphUserId } } } } = context +async function initiateProfileChange(context: ProfileContext, event: any) { + const { + connections: { + graphql, + redis + }, + user: { + account: { + address, + phone_number , + }, + graph: { + account: { + id: graphAccountId, + marketplace: cachedMarketplace, + }, + personalInformation: cachedPersonalInformation, + user: { + id: graphUserId + } + } + } + } = context const { input } = event - const { personal_information } = context.data await validatePin(context, input) + const isUpdatingMarketplace = (cachedMarketplace?.marketplace_name !== undefined && cachedMarketplace?.marketplace_name !== null) + const isUpdatingProfile = (cachedPersonalInformation !== undefined && cachedPersonalInformation !== null) + try { - if (personal_information){ + if (context?.data?.personalInformation){ let updatedProfile: Partial = { - ...personal_information, - user_identifier: graphUserId - } - await upsertPersonalInformation(address, graphql, updatedProfile, phone_number, p_redis) + ...context.data.personalInformation, + user_identifier: graphUserId + } + + if(isUpdatingProfile){ + await updateGraphPersonalInformation(address, graphql, updatedProfile, phone_number, redis.persistent) + } else { + await createGraphPersonalInformation(address, graphql, updatedProfile, phone_number, redis.persistent) + } } - if (context.data?.marketplace) { - const marketplace = { - account: graphAccountId, - marketplace_name: context.data.marketplace + if (context?.data?.marketplaceName) { + let marketplace: Partial = { + marketplace_name: context.data.marketplaceName + } + + if(isUpdatingMarketplace){ + await updateGraphMarketplace(cachedMarketplace.id, graphql, marketplace, phone_number, redis.persistent) + } else { + marketplace.account = graphAccountId + await createGraphMarketplace( graphql, marketplace, phone_number, redis.persistent) } - await upsertMarketplace(graphql, marketplace, phone_number, p_redis) + } return { success: true } - } catch (error) { - throw new MachineError(ProfileError.CHANGE_ERROR, "Error changing profile.") + } catch (error: any) { + throw new MachineError(ProfileError.CHANGE_ERROR, `${error.message}: ${error.stack}`) } } -function isLoadError(_: BaseContext, event: any) { +function isLoadError(_: ProfileContext, event: any) { return event.data.code === ProfileError.LOAD_ERROR } -function isChangeError(_: BaseContext, event: any) { +function isChangeError(_: ProfileContext, event: any) { return event.data.code === ProfileError.CHANGE_ERROR } -function isValidName(_: BaseContext, event: any) { +function isValidName(_: ProfileContext, event: any) { return /^[A-Za-z]+([][A-Za-z]+)?$/.test(event.input) } -function saveFamilyName(context: BaseContext , event: any) { - context.data = { - ...(context.data || {}), - personal_information: { - ...(context.data.personal_information || {}), - family_name: event.input - } +function saveFamilyName(context: ProfileContext , event: any) { + if (!context.data.personalInformation) { + context.data.personalInformation = {}; } + context.data.personalInformation.family_name = event.input return context } -function saveGivenNames(context: BaseContext , event: any) { - context.data = { - ...(context.data || {}), - personal_information: { - ...(context.data?.personal_information || {}), - given_names: event.input - } +function saveGivenNames(context: ProfileContext, event: any) { + // Initialize personalInformation object if it's undefined + if (!context.data.personalInformation) { + context.data.personalInformation = {}; } - return context + + context.data.personalInformation.given_names = event.input; + return context; } -function isValidGender(context: BaseContext , event: any) { + +function isValidGender(context: ProfileContext , event: any) { return event.input === "1" || event.input === "2" } -function saveGender(context: BaseContext , event: any) { - context.data = { - ...(context.data || {}), - personal_information: { - ...(context.data?.personal_information || {}), - gender: event.input === "1" ? Gender.MALE : Gender.FEMALE - } +function saveGender(context: ProfileContext , _: any) { + if (!context.data.personalInformation) { + context.data.personalInformation = {}; } + context.data.personalInformation.gender = "1" ? Gender.MALE : Gender.FEMALE return context } -function isValidYOB(context: BaseContext , event: any) { +function isValidYOB(context: ProfileContext , event: any) { const year = parseInt(event.input) if (isNaN(year)) return false return year >= 1900 && year <= 2100; } -function saveYOB(context: BaseContext , event: any) { - context.data = { - ...(context.data || {}), - personal_information: { - ...(context.data?.personal_information || {}), - year_of_birth: parseInt(event.input) - } +function saveYOB(context: ProfileContext , event: any) { + if (!context.data.personalInformation) { + context.data.personalInformation = {}; } + context.data.personalInformation.year_of_birth = parseInt(event.input) return context } -function isValidLocation(context: BaseContext , event: any) { +function isValidLocation(context: ProfileContext , event: any) { return /^[a-zA-Z\s]*$/.test(event.input) } -function saveLocation(context: BaseContext , event: any) { - context.data = { - ...(context.data || {}), - personal_information: { - ...(context.data?.personal_information || {}), - location_name: event.input - } +function saveLocation(context: ProfileContext , event: any) { + if (!context.data.personalInformation) { + context.data.personalInformation = {}; } + context.data.personalInformation.location_name = event.input return context } -function isValidMarketplace(_: BaseContext, event: any) { +function isValidMarketplaceName(_: ProfileContext, event: any) { return isValidName(_, event) } -function saveMarketPlace(context: BaseContext , event: any) { - context.data = { - ...(context.data || {}), - marketplace: event.input - } - return context +function saveMarketplaceName(context: ProfileContext , event: any) { + context.data.marketplaceName = event.input } -function genderAbsent(context: BaseContext) { - const gender = context.user?.graph?.user?.personal_information?.gender; +function genderAbsent(context: ProfileContext) { + const gender = context.user?.graph?.personalInformation?.gender; return gender === null || gender === undefined; } -function YOBAbsent(context: BaseContext) { - const yob = context.user?.graph?.user?.personal_information?.year_of_birth; +function YOBAbsent(context: ProfileContext) { + const yob = context.user?.graph?.personalInformation?.year_of_birth; return yob === null || yob === undefined; } -function locationAbsent(context: BaseContext) { - const location = context.user?.graph?.user?.personal_information?.location_name; +function locationAbsent(context: ProfileContext) { + const location = context.user?.graph?.personalInformation?.location_name; return location === null || location === undefined; } -function marketplaceAbsent(context: BaseContext) { - const services = context.user?.graph?.marketplace?.marketplace_name; +function marketplaceNameAbsent(context: ProfileContext) { + const services = context.user?.graph?.account?.marketplace?.marketplace_name; return services === null || services === undefined; } -async function loadPersonalInformation(context: BaseContext, event: any) { +async function loadPersonalInformation(context: ProfileContext, event: any) { const { input } = event await validatePin(context, input) return { success: true } } -export async function profileTranslations(context: BaseContext, state: string, translator: any) { +async function profileTranslations(context: ProfileContext, state: string, translator: any) { const { user: { account: { language }, graph } } = context; if (state === "displayingProfile") { const notProvided = tHelpers("notProvided", language); - const { user } = graph; - const name = user?.personal_information?.given_names && user?.personal_information?.family_name ? `${user.personal_information.given_names} ${user.personal_information.family_name}` : notProvided; - const gender = user?.personal_information?.gender; + const name = graph?.personalInformation?.given_names && graph?.personalInformation?.family_name ? `${graph.personalInformation.given_names} ${graph.personalInformation.family_name}` : notProvided; + const gender = graph?.personalInformation?.gender; const genderText = gender ? tHelpers(gender, language) : notProvided; - const age = user?.personal_information?.year_of_birth ? new Date().getFullYear() - user.personal_information.year_of_birth : notProvided; - const location = user?.personal_information?.location_name || notProvided; - const marketplace = graph.marketplace?.marketplace_name || notProvided; + const age = graph?.personalInformation?.year_of_birth ? new Date().getFullYear() - graph.personalInformation.year_of_birth : notProvided; + const location = graph?.personalInformation?.location_name || notProvided; + const marketplace = graph?.account?.marketplace?.marketplace_name || notProvided; return await translate(state, translator, { name: `${tHelpers("name", language)} ${name}`, gender: `${tHelpers("gender", language)} ${genderText}`, @@ -452,4 +483,9 @@ export async function profileTranslations(context: BaseContext, state: string, t }); } return await translate(state, translator); +} + +export const profileMachine: MachineInterface = { + stateMachine, + translate: profileTranslations } \ No newline at end of file diff --git a/src/machines/registration.ts b/src/machines/registration.ts index 2b02e37..8c58a07 100644 --- a/src/machines/registration.ts +++ b/src/machines/registration.ts @@ -1,33 +1,32 @@ import { createMachine, raise } from 'xstate'; import { - BaseContext, - BaseEvent, - getLanguage, isOption00, isOption11, isOption22, isSuccess, - languageOptions, + MachineEvent, MachineId, - translate, - updateErrorMessages, - User + MachineInterface, + updateErrorMessages } from '@machines/utils'; -import { Address, supportedLanguages } from '@lib/ussd/utils'; +import { ErrorCodes, MachineError, SystemError } from '@lib/errors'; import { createWallet } from '@lib/custodail'; -import { createTracker, CustodialTaskType } from '@db/models/custodailTasks'; -import { createAccount } from '@db/models/account'; -import { createGraphUser } from '@lib/graph/user'; -import { createGraphAccount, GraphAccountTypes } from '@lib/graph/account'; -import { ContextError, ErrorCodes, MachineError, SystemError } from '@lib/errors'; -import { Cache } from '@utils/redis'; +import { createTracker, TaskType } from '@db/models/custodailTasks'; +import { AccountService } from '@services/account'; +import { createGraphAccount, createGraphUser, GraphAccountTypes } from '@lib/graph/user'; +import { UserService } from '@services/user'; +import { getLanguage, languageOptions, supportedLanguages, translate } from '@i18n/translators'; +import { LanguagesContext } from '@machines/languages'; + enum RegistrationError { ACCOUNT_CREATION_FAILED = 'ACCOUNT_CREATION_FAILED', } +export interface RegistrationContext extends LanguagesContext {} + -export const registrationMachine = createMachine({ +const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOkwCcx0AXAqAQU0wHsBXfagYgmcJIIBuzANZgSaLHkKkKVWvgZM2HBIOaYauXgG0ADAF09+xKAAOzWLlq8TIAB6IATAHZHukgDYALLo+OArB4AzAAcAIwRADQgAJ6IYbq6-iTOukGOYW4eYV5h4QC++dESOATEZJSaCows7Fxg5OTM5CSmADY0AGbNqOIYpdIVcnQ1ytSq+EIa1vhGRrbmljO2Dggubp4+foGhEWHRcQgJISEkXs4el2FBAJyuSSGFxf1S5Z245LDUADLoCqzoGAAZTAXAAKgAlegAOSBAEkwfMkCBFlYtPgVogvP5nCRdDcgs5ArpnDdciT-Ad4jkvGd-OlfCEbkl-KyniASq9SO9Pj8-lAAcDQZxITD4YiwsZkajlsjVtjcfjCcTSeSiVSEKEPCR-IlEjTHCEgl4guzOWVuR8vr9-oCwCDwVDYQjtI4pWYLGibHKsTi8QSiR4SWSEurYohnEFtUTEjdLs4iVGzS8LSQedb+YL7cLRc7EUF3SjPbLQPK-UrA8G1ZTw2tdI4SAFEkFboEPKz-MnJKnBOg2rgIDaBXaAPKmGacCEAUUhAE0kR6lujMWsEtqbvSwjcmWTdWFnBrwjq9T4bmFAtcvF2BuVYGAWPhB5m7Q6RU7xQui0vvaX4m53HkRKpI4bgtucGoJIkJB3JcXhGro2LxteXIkHeD5PraQqOmKLqSgsxbLj6CBeBuZw3OR3gkuc-iZBqLbanqujnlGFLIamaG8Bhw5YW+OGIm6+HfhiRGZFBgE4vWoHGgeta6u4ckIfiCE5GSV5FByKaDBxj5Dlmr65h+BaCV6wm-kc-4kOJwFSeBtbZAx+ppME3gEo86nmlp96cbpL45u+LpeIWMqEWZokASEQGSWk0l0Y4QR4nqjgeORFwwR4bGDNQeDkFxel+Xxn7BT+9jxMyYQ6tsgT+Ke3ghIefoKUyzh5DibnPN2mXZblvnYXm2h4dKBHFasmShDqAa3EyW7OCEjgati7iMdVs1ZP4jgZeUWUfN1PEGS6AmDUJK5bkxFUBFVNVwfNpFNhESQbtiYTeBtpBbTlPm7f5+ZBUNpklUcZVnay7aXXVtZeBDKQkXcmQtaSYSFOp+DMBAcC2B5xDGSW-0ALQeBqeMJYxxN6s4L0kFgtQcAAwpUMxTo0zRYyF-1BDSZy+EE-ghBDPMhPjtbHNGaQgUEaQ3I4zJtRpHXlJTYy08MvBAqwTBwPAh0mSubOQ1sXM83BcEC4c27laBbjhNzwNk+5mnlLIVSKFT1DM8NTiS2bzIeAmNGEsENYmzz0H0nFYvnpcujSxjpBgHYViu39qwgYamyc-SSReI4EMav4BJE5HEuEk9Jzk+mfKYdmLua9j8pzbWJwkOkOTZKykuzSEna27LpC9v2O1gGO2NFYn7sp5R-ti9VWdeBBqTQYl4TnuRLg2+1N6kNp-cOgnK4JrS+6AWz3uBBcsU3CkclH4GEWml368kG9W+gjvIk0biSWBHB27nGSxtYiaF9Ej-lCJGO4iN8hAA */ id: MachineId.REGISTRATION, initial: "firstLanguageSet", @@ -117,16 +116,13 @@ export const registrationMachine = createMachine({ } }) -function isAccountCreationError(context: BaseContext, event: any) { +function isAccountCreationError(context: RegistrationContext, event: any) { return event.data.code === RegistrationError.ACCOUNT_CREATION_FAILED || event.data.code === ErrorCodes.SYSTEM_ERROR } -async function initiateAccountCreation(context: BaseContext) { - const { data: { languages }, resources: { db, graphql, p_redis }, ussd: { phoneNumber } } = context +async function initiateAccountCreation(context: RegistrationContext) { + const { connections: { db, graphql, redis }, data: { selectedLanguage }, ussd: { phoneNumber } } = context - if (!languages?.selected){ - throw new MachineError(ContextError.MALFORMED_CONTEXT, 'selected language is missing.') - } let account, graphAccount, graphUser, wallet; try { @@ -136,27 +132,27 @@ async function initiateAccountCreation(context: BaseContext) { } if(!wallet.result) { - throw new SystemError("Wallet creation failed.") + throw new SystemError(`Wallet creation failed. ${wallet.errorCode}: ${wallet.message}`) } try { await createTracker(db, { address: wallet.result.publicKey, task_reference: wallet.result.trackingId, - task_type: CustodialTaskType.REGISTER + task_type: TaskType.REGISTER }) } catch (error: any) { - throw new SystemError(error.message) + throw new SystemError(`Tracker creation failed. ${error.message}- ${error.stack}`) } try { - account = await createAccount({ - address: wallet.result.publicKey as Address, - language: languages.selected, + account = await new AccountService(db, redis.persistent).create({ + address: wallet.result.publicKey, + language: selectedLanguage, phone_number: phoneNumber - }, db, p_redis) + }) } catch (error: any) { - throw new SystemError(error.message) + throw new SystemError(`Account creation failed. ${error.message}- ${error.stack}`) } if(!account){ @@ -166,11 +162,11 @@ async function initiateAccountCreation(context: BaseContext) { try { graphUser = await createGraphUser(graphql, { activated: false, - interface_identifier: String(account.id), + interface_identifier: phoneNumber, interface_type: "USSD", }) } catch (error: any) { - throw new SystemError(error.message) + throw new SystemError(`Graph user creation failed. ${error.message}- ${error.stack}`) } if(!graphUser){ @@ -183,7 +179,7 @@ async function initiateAccountCreation(context: BaseContext) { blockchain_address: wallet.result.publicKey, user_identifier: graphUser.id }) } catch (error: any) { - throw new SystemError(error.message) + throw new SystemError(`Graph account creation failed. ${error.message}- ${error.stack}`) } if(!graphAccount){ @@ -191,8 +187,8 @@ async function initiateAccountCreation(context: BaseContext) { } try { - const cache = new Cache(p_redis, phoneNumber) - await cache.updateJSON({ + const userService = new UserService(phoneNumber, redis.persistent) + await userService.update({ graph: { account: { id: graphAccount.id }, user: { id: graphUser.id } @@ -204,32 +200,31 @@ async function initiateAccountCreation(context: BaseContext) { } } -function isValidLanguageOption(context: BaseContext, event: any) { +function isValidLanguageOption(context: RegistrationContext, event: any) { const index = parseInt(event.input) - 1; return Object.keys(supportedLanguages)[index] !== undefined } -function saveLanguageOption(context: BaseContext, event: any) { - const { input } = event - context.data = { - ...(context.data || {}), - languages: { - selected: getLanguage(input) - } - } +function saveLanguageOption(context: RegistrationContext, event: any) { + context.data.selectedLanguage = getLanguage(event.input) return context } -export async function registrationTranslations(state: string, translator: any) { +async function registrationTranslations(context: any, state: string, translator: any) { const languages = await languageOptions() switch(state){ case "firstLanguageSet": - return await translate(state, translator,{ languages: languages[0] }) + return translate(state, translator,{ languages: languages[0] }) case "secondLanguageSet": - return await translate(state, translator,{ languages: languages[1] }) + return translate(state, translator,{ languages: languages[1] }) case "thirdLanguageSet": - return await translate(state, translator,{ languages: languages[2] }) + return translate(state, translator,{ languages: languages[2] }) default: - return await translate(state, translator) + return translate(state, translator) } } + +export const registrationMachine: MachineInterface = { + stateMachine, + translate: registrationTranslations +} diff --git a/src/machines/settings.ts b/src/machines/settings.ts new file mode 100644 index 0000000..44362c6 --- /dev/null +++ b/src/machines/settings.ts @@ -0,0 +1,70 @@ +import { createMachine } from 'xstate'; +import { + intermediateMachineTranslations, + isOption1, + isOption2, + isOption3, + isOption4, + isOption5, + MachineEvent, + MachineId, + MachineInterface, + UserContext +} from '@machines/utils'; + +const stateMachine = createMachine({ + id: MachineId.SETTINGS, + initial: "settingsMenu", + states: { + balances: { + description: 'Transitions to balances machine', + type: 'final' + }, + language: { + description: 'Transitions to language machine', + type: 'final' + }, + mainMenu: { + description: 'Transitions to main menu machine', + type: 'final' + }, + pinManagement: { + description: 'Transitions to pin management machine', + type: 'final' + }, + profile: { + description: 'Transitions to profile machine', + type: 'final' + }, + settingsMenu: { + description: 'Displays account management menu.', + on: { + BACK: 'mainMenu', + TRANSIT: [ + { target: 'profile', cond: 'isOption1' }, + { target: 'language', cond: 'isOption2' }, + { target: 'balances', cond: 'isOption3' }, + { target: 'statement', cond: 'isOption4' }, + { target: 'pinManagement', cond: 'isOption5' } + ] + } + }, + statement: { + description: 'Transitions to statement machine', + type: 'final' + } + } +},{ + guards: { + isOption1, + isOption2, + isOption3, + isOption4, + isOption5 + } +}) + +export const settingsMachine: MachineInterface = { + stateMachine, + translate: intermediateMachineTranslations +} \ No newline at end of file diff --git a/src/machines/socialRecovery.ts b/src/machines/socialRecovery.ts index 6dd5c05..4b12dd4 100644 --- a/src/machines/socialRecovery.ts +++ b/src/machines/socialRecovery.ts @@ -1,7 +1,5 @@ import { createMachine, raise } from 'xstate'; import { - BaseContext, - BaseEvent, isOption00, isOption1, isOption11, @@ -10,22 +8,22 @@ import { isOption3, isOption9, isSuccess, + isValidPhoneNumber, + MachineEvent, MachineId, - menuPages, - translate, + MachineInterface, updateErrorMessages, - User, + UserContext, validateTargetUser } from '@machines/utils'; -import { sanitizePhoneNumber } from '@utils/phoneNumber'; import { isBlocked, isValidPin, validatePin } from '@machines/auth'; -import { ContextError, MachineError } from '@lib/errors'; -import { addGuardian, removeGuardian } from '@db/models/guardian'; -import { Cache } from '@utils/redis'; -import { tHelpers } from '@i18n/translators'; +import { ContextError, MachineError, SystemError } from '@lib/errors'; +import { tHelpers, translate } from '@i18n/translators'; import { Redis as RedisClient } from 'ioredis'; -import { getTag } from '@lib/ussd/utils'; import { Locales } from '@i18n/i18n-types'; +import { getUserTag } from '@services/user'; +import { AccountService } from '@services/account'; +import { menuPages } from '@lib/ussd'; enum SocialRecoveryError { @@ -36,8 +34,18 @@ enum SocialRecoveryError { NOT_ADDED = "NOT_ADDED", } +export interface SocialRecoveryContext extends UserContext { + data: { + guardianToAddEntry: string, + guardianToRemoveEntry: string, + loadedGuardians: string[], + validGuardianToAdd: string, + validGuardianToRemove?: string, + } +} -export const socialRecoveryMachine = createMachine({ + +export const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOnQFcAXbAewCdcAvAqAcXPTol3XwEEI3Srhr4AxBFFgSBAG40A1tIrV6TFu07deAoSPwBtAAwBdRKAAONWLmGjzIAB6IALAE4AzCSM+jAJiMANgCAwIAOFwAaEABPRABGIxc-EjcwzzSwvwj4j0CAX3zotCw8QlIVWgZmfDYOLh5+QVt9MTA6OnoSCwAbdEoAM3pUMioq9VrNBp1mu0NTBysbOYdnBHcvX38gkKCI6LiEeICAdhI-eIBWNKNLv0vAtIKikBKcAmJR1WqNeu0mvSiNodLq9fpDOgjSpqGp1LSNXQtUQGeJmJAgJZI-CrVyeby+UK7cJRWIJDwnS4kE4eDKJS7uQJGMKFYoYd7lL7jWFTf6IubAzp0bp9QbDTkw37wmaAwx+NGWaxYnHrPFbQnbYkHBLhM7UtwuLIuW7pQKXFmvNllT7Qn6TP6NABKYFQNFk6B6EikMnw8iU4ttcOm+CdLrdPWM8oxipW6LWfhciRINNuJ1T8ROfguJy1CA8Hhc3jyeaNHni6UzLnNbytFTGErtUuDztd7oFoJFEKhdYDPMdzbDEcW0f0yvjieTl1TJ3TmfTOcSYTObkCjPTl3puVuVctH1r3wmgf+IZbHvaguF4LFNoPvd4x4HqKHyxHscQY6MSbcKbTGazObH3hhB4fh5vq-ggYElYvNWu7+je9p3v2rZnu2l6QnB3IIU2obugYcpPkqr4IO+n7ftOv5zqSRzAfiPjxFc06BPETFQaypSwdesIAGq4GAADut74LAnqEN6vrKN2B48fxgmwIO6KYjGoBrBstEakS+xUQE64kGE1zMamWSlqa27sRynEsNJAlYcJKFCmCoroRZtRWbJ8kKs+9hEapao7BqmmHMc9JUlcX6QeEQHuKZ7LWpJ3G8dZja2SC9kdlecWWQlbmPgpw5ecpuKbASfmhAFb7MR+iRuBVwThCB0U1hhmUyTZbapWhXb7vFLVJXhkaKS+BUqkVPjqqVJKHH4wRhCQVUBEYuSeGWZrQTuHJgPglDtJKQYACo0PeYBiAAQnwADCADS7lRp52JEX4JyQSQBouAaIEeEYH2BPOn1nC4K6fek9HAYEJwNbBG1bQwDZ7Qd-ZHbtDp8AAcgAygAkrt10DflThvicRokIy+r6h4RmXPEE2IBmBYgfqLgE8EZN+OD62bdttTIz1QYnedV0LLlt2jo9BYvW9eafUE84Gmc9JuFNJxuNObiXMyq1mZ8kMc1AXOJTziMoxjWMCx5hFDQ9hPE+4LjMx4FNU0ci5nBcLHxG48vHG4rOa+z0NQAACgQfCsLzl3Y3ld3mwmH4TlOM5-lR6b0c9K70aajzvWD6sxaQWt+4H-AhwbaOY+HQtEWulIPCBBMJgEbvzpcNIhdcYSgwEealt7ue+ywBcOiHp1hybN1m3jRwM2cE5ky41z-Q9OaMl4Br6qrlNGDcHjdyQed9wQA9iMXRtl2PayU6mpFNxEc-BNmVH5vERPTYu67kiBK1sTnO+97UBdcYPfMT5KXHg9J6YsjIfS+v+ZIbgSCqyYgEBMj0VxZ0-o1Xev8CD-0PkjEuxt+oR1HKaSq04gKlgpKDKWWkDQfiZIye4wQrgmWzo1KAWE+T6FRuQTAmA4DCSHvzAh5chrnynl+K+s99S32lmTFuYQsjrkeKFbebDGwcNEFwnhfCcGG1LiPHGkdx6iMvjPG+C8qLrhSGkfUit3BhHTMwtBsFVFBnvO6TRvDYD8MAfowh90RbPQiOLSBVDDjJECKkdIDMmQ2wWnmD+FoNakBcUeJCPQPHaKPnooRp9EB5mnGpfMdiLhuBzAmR+K9Xr2PXCcW4W8WGwTkO6XAEAC7BzEA6AAoojAAmkAwaRjJ4mOvlI8xgVakzWXExZiDx5b5m3k0noLT+4hy6b0-puM4wUinscG28j5F+CmTmexz13bu2CBvO2Ss1ZOI5Is5ZWDVndIdH03xwijFKwLBOScG9alXH-JBWB8TIofSmg8beFgCCyQADI0HQBASAodBEEWAWfIZ08RnzzvoFFe5xQr5kCGTYIs8IVQpsrC+FiKsn4JRQMtFF8MWSKxf+DeH5qrVQuPGYkD1t6wBoJgHgPQnSYFdO0GIABZDa5AkUbMMXGaOl844UWxWSNucD3DMTJkyekRhUGJK-nygV7phWiroBKqVOi8GytHAq2OP5ZwqonuSbw7gLiqy-PGTwvL+WCpNbIMVkr8DSupSiHJqK3y2vEUqh1OYnipFNE8LIy4yz1NuZ8Q1vqwAiv9WawNwbcHH3woLXJxFI1kXjpRSa5JYHao9rPexMtt5hhaf0Ha-x9q6BEtIOQihpDNogK2mG7aaC6GtRXdF4jTGjMdWuWB-16HpmpLkbITbmkDuEEOxoHbBBtQvI5EY-bB2Hi3SOwQY6RETrqZi6Rid3ZLgBqvMCJw26rqWeuttJ7DpdrEr2kgh6N3Ht4PtQ657x4+WKhc8aOZVZWKiZcJIrc7gswaRyf9H6gNw1DEdOye7Ox-rXUewSwH4agZUqqCDGkHZBMiaTUGQFn1QRePgGgCL4DohguUWlmzEAAFpvpUR45SM5wmRP0e3lgEV5BNrHR6PypQEAuNyrfIuGtvgrhtyCCg+cZYUjTlbm7FWIF0ziYypu6UY8DHKmSPOFi6r6Y-OAsxVi+rGrOUA9hE8inlRuwJpfH5SsFqXH-HiOuel5YXGvvMlDsUurNT1v8Njptw3rD8P+dcBZ2WaptiuGe28MHueI1hrz90VNqXU4yFcoNjmvRTtccLQQ6vOY4z7KGLBdaCWK+bUrWxyuaaq4ncIsDGK-SyIuY4eWf4ByDqwTrID-BVwetkexZYjT8cCoNkKS9n3ZGnMhtNPdWuYODDN4tyX6IUl0j4aci0vzVTCNB64VIaSz0sc+q+E3DtTfwP-WbcZutqfgX1x18YKaFmmeEBNoQ8uOFsL9vJ1wY6M38ISg0jxoOHJo9E1uG94gqPYbMfQnSUpw4QCrGO4i3ZZDdokB2uRqTxrnjtzwuq9XNeSfjmUGSvEk-opTOB8jiw0nloS1LickgpH+tcek9xjiHLx42NxPQieChJ0zsrgPKvA+bh9a5k5II-OpPL1xaSueJdHslw5jJUjJAJh9BjekcxkyG895IekjQC6a2tT49zWnTZJ2Ty+lOSk05s1b+dC1QH0UXJ7pJYk10rNV-9uiGutNUSyLpRIzEIj+DJm3VNLnGk+nj48nnl6JFmOB4yD8QFrjbffpTfPbOSCyfhcr+g-v4OB7SMHpI-51NJjtrt2eacVakvwDCuFCKFOnbpQkeiNamEgXuDA+7WlbiUniYrFcr1fpj-FbwdAMBUCQzzYnxW6uNOa-nEWOzVx3AxMsd6o1Qqs2mvNUG1XiQPyPXS3pMIOwVZpY7gqR3Z7hMxMw9IghX0W0AMiNT1p8ktZ8jgkhH4Jx0wgJ5Z1xV9JopovAa9ttO4kgvVotSA0MzN8BCtRUSdrMqJts5EhdztHgVpCggA */ id: MachineId.SOCIAL_RECOVERY, initial: "socialRecoveryMenu", @@ -301,151 +309,94 @@ export const socialRecoveryMachine = createMachine({ } }) -function isLoadError(context: BaseContext, event: any) { +function isLoadError(context: SocialRecoveryContext, event: any) { return event.data.code === SocialRecoveryError.LOAD_ERROR; } -function isAdditionError(context: BaseContext, event: any) { +function isAdditionError(context: SocialRecoveryContext, event: any) { return event.data.code === SocialRecoveryError.GUARDIAN_ADDITION_ERROR; } -function isRemovalError(context: BaseContext, event: any) { +function isRemovalError(context: SocialRecoveryContext, event: any) { return event.data.code === SocialRecoveryError.GUARDIAN_REMOVAL_ERROR; } -function isValidPhoneNumber(context: BaseContext, event: any) { - const { ussd: { countryCode } } = context; - try { - sanitizePhoneNumber(event.input, countryCode); - return true - } catch (e) { - return false; - } -} - -function saveValidatedGuardianToAdd(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - guardians: { - ...(context.data?.guardians || {}), - validated: { - ...(context.data?.guardians?.validated || {}), - toAdd: event.data.guardian - } - } - } +function saveValidatedGuardianToAdd(context: SocialRecoveryContext, event: any) { + context.data.validGuardianToAdd = event.data.guardian; return context; } -function saveValidatedGuardianToRemove(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - guardians: { - ...(context.data?.guardians || {}), - validated: { - ...(context.data?.guardians?.validated || {}), - toRemove: event.data.guardian - } - } - } +function saveValidatedGuardianToRemove(context: SocialRecoveryContext, event: any) { + context.data.validGuardianToRemove = event.data.guardian; return context; } -function saveLoadedGuardians(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - guardians: { - ...(context.data?.guardians || {}), - loaded: event.data.guardians - } - } +function saveLoadedGuardians(context: SocialRecoveryContext, event: any) { + context.data.loadedGuardians = event.data.guardians; return context; } -function saveGuardianToAddEntry(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - guardians: { - ...(context.data?.guardians || {}), - entry: { - ...(context.data?.guardians?.entry || {}), - toAdd: event.input - } - } - } +function saveGuardianToAddEntry(context: SocialRecoveryContext, event: any) { + context.data.guardianToAddEntry = event.input; return context; } -function saveGuardianToRemoveEntry(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - guardians: { - ...(context.data?.guardians || {}), - entry: { - ...(context.data?.guardians?.entry || {}), - toRemove: event.input - } - } - } +function saveGuardianToRemoveEntry(context: SocialRecoveryContext, event: any) { + context.data.guardianToRemoveEntry = event.input; return context; } -async function initiateGuardianAddition(context: BaseContext, event: any) { +async function initiateGuardianAddition(context: SocialRecoveryContext, event: any) { const { - data: { guardians }, - resources: { db, p_redis }, + data, + connections: { db, redis }, user: { account: { phone_number } } } = context const { input } = event await validatePin(context, input) - if(!guardians?.validated?.toAdd) { + if(!data.validGuardianToAdd) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Guardian to add missing from context.") } try { - await addGuardian(db, guardians.validated.toAdd, p_redis, phone_number) + await new AccountService(db, redis.persistent).addGuardian(data.validGuardianToAdd, phone_number) return { success: true } } catch (error: any) { throw new MachineError(SocialRecoveryError.GUARDIAN_ADDITION_ERROR, error.message) } } -async function initiateGuardianRemoval(context: BaseContext, event: any) { +async function initiateGuardianRemoval(context: SocialRecoveryContext, event: any) { const { - data: { guardians }, - resources: { db, p_redis }, + data, + connections: { db, redis }, user: { account: { phone_number } } } = context const { input } = event await validatePin(context, input) - if(!guardians?.validated?.toRemove) { + if(!data.validGuardianToRemove) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Guardian to remove missing from context.") } try { - await removeGuardian(db, guardians.validated.toRemove, p_redis, phone_number) + await new AccountService(db, redis.persistent).removeGuardian(data.validGuardianToRemove, phone_number) return { success: true } } catch (error: any) { throw new MachineError(SocialRecoveryError.GUARDIAN_REMOVAL_ERROR, error.message) } } -async function loadPinGuardians(context: BaseContext, event: any) { - const { resources: { p_redis }, user: { account: { language, phone_number } } } = context +async function loadPinGuardians(context: SocialRecoveryContext, event: any) { + const { connections: { db, redis }, user: { account: { language, phone_number } } } = context const { input } = event await validatePin(context, input) - // load guardians from redis. try { - const cache = new Cache(p_redis, phone_number); - const user = await cache.getJSON() - - const guardians = user?.guardians || [] - - const formattedGuardians = await formatGuardians(guardians, language, p_redis) + const guardians = await new AccountService(db, redis.persistent).getAllGuardians(phone_number) || [] + const formattedGuardians = await formatGuardians(guardians, language, redis.persistent) return { guardians: formattedGuardians, success: true } } catch (error: any) { throw new MachineError(SocialRecoveryError.LOAD_ERROR, error.message) @@ -457,69 +408,68 @@ async function formatGuardians(guardians: string[], language: Locales, redis: Re const placeholder = tHelpers("noMoreGuardians", language) const formattedGuardians = [] for (const guardian of guardians) { - const tag = await getTag(guardian, redis) + const tag = await getUserTag(guardian, redis) formattedGuardians.push(tag) } return await menuPages(formattedGuardians, placeholder) } -async function validateGuardianToAdd(context: BaseContext, event: any) { - const { input } = event +async function validateGuardianToAdd(context: SocialRecoveryContext, event: any) { + const guardians = context.user.account.guardians || []; + const guardian = await validateTargetUser(context, event.input) + + if(!guardian?.account?.phone_number) { + throw new SystemError("Guardian phone number missing.") + } - const guardianUser = await validateTargetUser(context, input) - const { account: guardianAccount } = guardianUser - const guardian = guardianAccount.phone_number - const guardians = context.user.guardians || []; - if (guardians.includes(guardian)) { + if (guardians.includes(guardian.account.phone_number)) { throw new MachineError(SocialRecoveryError.ALREADY_ADDED, "Already a guardian.") } - return { guardian: guardian, success: true } + return { guardian: guardian.account.phone_number, success: true } } -async function validateGuardianToRemove(context: BaseContext, event: any) { - const { input } = event +async function validateGuardianToRemove(context: SocialRecoveryContext, event: any) { + const guardians = context.user.account.guardians || []; + const guardian = await validateTargetUser(context, event.input) - const guardianUser = await validateTargetUser(context, input) - const { account: guardianAccount } = guardianUser - const guardian = guardianAccount.phone_number - const guardians = context.user.guardians || []; - if (!guardians.includes(guardian)) { + if(!guardian?.account?.phone_number) { + throw new SystemError("Guardian phone number missing.") + } + if (!guardians.includes(guardian.account.phone_number)) { throw new MachineError(SocialRecoveryError.NOT_ADDED, "Not a guardian.") } - return { guardian: guardian, success: true } + return { guardian: guardian.account.phone_number, success: true } } -export async function socialRecoveryTranslations(context: BaseContext, state: string, translator: any){ - const { data: { guardians }, resources: { p_redis } } = context +async function socialRecoveryTranslations(context: SocialRecoveryContext, state: string, translator: any){ + const { data} = context switch (state) { case 'guardianAdditionSuccess': case 'guardianAdditionError': { - if (!guardians?.validated?.toAdd) throw new MachineError(ContextError.MALFORMED_CONTEXT, 'Validated guardian to add missing from context.'); - const guardian = await getTag(guardians.validated.toAdd, p_redis); - return await translate(state, translator, { guardian: guardian }); + return await translate(state, translator, { guardian: data.validGuardianToAdd }); } case 'guardianRemovalSuccess': case 'guardianRemovalError': { - if (!guardians?.validated?.toRemove) throw new MachineError(ContextError.MALFORMED_CONTEXT, 'Validated guardian to remove missing from context.'); - const guardian = await getTag(guardians.validated.toRemove, p_redis); - return await translate(state, translator, { guardian: guardian }); + return await translate(state, translator, { guardian: data.validGuardianToRemove }); } case "firstGuardiansSet": { - if (!guardians?.loaded) throw new MachineError(ContextError.MALFORMED_CONTEXT, "Guardians missing from context.") - return await translate(state, translator, { guardians: guardians.loaded[0] }); + return await translate(state, translator, { guardians: data.loadedGuardians[0] }); } case "secondGuardiansSet": { - if (!guardians?.loaded) throw new MachineError(ContextError.MALFORMED_CONTEXT, "Guardians missing from context.") - return await translate(state, translator, { guardians: guardians.loaded[1] }); + return await translate(state, translator, { guardians: data.loadedGuardians[1] }); } case "thirdGuardiansSet": { - if (!guardians?.loaded) throw new MachineError(ContextError.MALFORMED_CONTEXT, "Guardians missing from context.") - return await translate(state, translator, { guardians: guardians.loaded[2] }); + return await translate(state, translator, { guardians: data.loadedGuardians[2] }); } default: return await translate(state, translator) } +} + +export const socialRecoveryMachine: MachineInterface = { + stateMachine, + translate: socialRecoveryTranslations } \ No newline at end of file diff --git a/src/machines/statement.ts b/src/machines/statement.ts index a2e3970..fc011ac 100644 --- a/src/machines/statement.ts +++ b/src/machines/statement.ts @@ -1,49 +1,34 @@ import { createMachine, raise } from 'xstate'; import { - BaseContext, - BaseEvent, isOption00, isOption11, isOption22, isSuccess, + MachineEvent, MachineId, - menuPages, - translate, - updateErrorMessages + MachineInterface, + updateErrorMessages, + UserContext } from '@machines/utils'; import { isBlocked, validatePin } from '@machines/auth'; -import { ContextError, MachineError } from '@lib/errors'; -import { Address, cashRounding, getTag } from '@lib/ussd/utils'; -import { tHelpers } from '@i18n/translators'; -import moment from 'moment-timezone'; +import { MachineError } from '@lib/errors'; +import { tHelpers, translate } from '@i18n/translators'; import { Redis as RedisClient } from 'ioredis'; import { GraphQLClient } from 'graphql-request'; -import { config } from '@/config'; -import { ethers } from 'ethers'; import { Locales } from '@i18n/i18n-types'; +import { Transaction, TransactionType } from '@services/transfer'; +import { menuPages } from '@lib/ussd'; -export enum TransactionType { - CREDIT = "CREDIT", - DEBIT = "DEBIT", -} - -export interface Transaction { - block: number; - from: Address; - symbol: string; - time: number; - to: Address; - transactionHash: string; - type: TransactionType; - value: number; -} - enum StatementError { LOAD_ERROR = "LOAD_ERROR" } -export const statementMachine = createMachine({ +export interface StatementContext extends UserContext { + statement: Transaction[] +} + +export const stateMachine = createMachine({ id: MachineId.STATEMENT, initial: "enteringPin", predictableActionArguments: true, @@ -147,82 +132,58 @@ export const statementMachine = createMachine({ } }) -function isLoadError(context: BaseContext, event: any) { +function isLoadError(context: StatementContext, event: any) { return event.data.code === StatementError.LOAD_ERROR; } -async function authorizeStatementView(context: BaseContext, event: any) { - const { resources: { graphql, p_redis }, user: { account: { language }, transactions } } = context - const { input } = event +async function authorizeStatementView(context: StatementContext, event: any) { + const { connections: { graphql, redis }, user: { account: { language }, statement } } = context - await validatePin(context, input) + await validatePin(context, event.input) try { - const statement = await generateStatement(graphql, language, p_redis, transactions || []) - return { success: true, statement: statement } + const formattedStatement = await formatStatement(graphql, language, redis.persistent, statement || []) + return { success: true, statement: formattedStatement } } catch (error) { throw new MachineError(StatementError.LOAD_ERROR, `Error loading statement.`) } } -async function generateStatement( graphql: GraphQLClient, language: Locales, redis: RedisClient, transactions: Transaction[]){ +async function formatStatement( graphql: GraphQLClient, language: Locales, redis: RedisClient, transactions: Transaction[]){ const placeholder = tHelpers("noMoreTransactions", language) - const sortedTransactions = [...transactions].sort((a, b) => b.time - a.time) - const formattedTransactions = await Promise.all(sortedTransactions - .map(async (transaction) => { - return formatTransaction(transaction, language, redis) + const formattedTransactions = await Promise.all(transactions.map(async (transaction) => { + const transactionType = transaction.type === TransactionType.CREDIT ? "credit" : "debit" + return tHelpers(transactionType, language, { + value: transaction.value, + time: transaction.timestamp, + sender: transaction.sender, + recipient: transaction.recipient, + symbol: transaction.symbol, + }) })) return await menuPages(formattedTransactions, placeholder) } -async function formatTransaction(transaction: Transaction, language: Locales, redis: RedisClient){ - const { from, symbol, time, to, type, value } = transaction - const transactionType = type === TransactionType.CREDIT ? "credit" : "debit" - const [recipient, sender] = await Promise.all([ - getTransactionActor(to, language, redis), - getTransactionActor(from, language, redis) - ]) - - const data = { - value: cashRounding(ethers.formatUnits(value, 6)), - symbol: symbol, - time: moment(new Date(time)).tz(config.TIMEZONE).format("DD-MM-YYYY HH:mm A"), - sender: sender, - recipient: recipient - } - return tHelpers(transactionType, language, data) -} - -async function getTransactionActor(address: string, language: Locales, redis: RedisClient){ - let actor = tHelpers("unknownAddress", language) - const phoneNumber = await redis.get(`address-phone-${address}`) - if (phoneNumber) { - actor = await getTag(phoneNumber, redis) - } - return actor -} - -function saveStatement(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - statement: event.data.statement - } +function saveStatement(context: StatementContext, event: any) { + context.data.statement = event.data.statement return context; } -export async function statementTranslations(context: BaseContext, state: string, translator: any){ +async function statementTranslations(context: StatementContext, state: string, translator: any){ const { data: { statement } } = context switch (state) { case "firstTransactionSet": - if(!statement) throw new MachineError(ContextError.MALFORMED_CONTEXT, "Statement missing in context object data.") return await translate(state, translator, {transactions: statement[0]}) case "secondTransactionSet": - if(!statement) throw new MachineError(ContextError.MALFORMED_CONTEXT, "Statement missing in context object data.") return await translate(state, translator, {transactions: statement[1]}) case "thirdTransactionSet": - if(!statement) throw new MachineError(ContextError.MALFORMED_CONTEXT, "Statement missing in context object data.") return await translate(state, translator, {transactions: statement[2]}) default: return await translate(state, translator) } } + +export const statementMachine: MachineInterface = { + stateMachine, + translate: statementTranslations +} \ No newline at end of file diff --git a/src/machines/transfer.ts b/src/machines/transfer.ts index fe1e8bf..4e04f0f 100644 --- a/src/machines/transfer.ts +++ b/src/machines/transfer.ts @@ -1,30 +1,38 @@ import { createMachine, raise } from 'xstate'; import { - BaseContext, - BaseEvent, isOption1, isOption9, isSuccess, isValidPhoneNumber, + MachineEvent, MachineId, - translate, updateErrorMessages, - validatePhoneNumber, + UserContext, validateTargetUser } from '@machines/utils'; -import { cashRounding } from '@lib/ussd/utils'; import { isBlocked, isValidPin, validatePin } from '@machines/auth'; import { custodialTransfer } from '@lib/custodail'; -import { createTracker, CustodialTaskType } from '@db/models/custodailTasks'; -import { ContextError, MachineError, SystemError } from '@lib/errors'; +import { createTracker, TaskType } from '@db/models/custodailTasks'; +import { BaseMachineError, ContextError, MachineError, SystemError } from '@lib/errors'; +import { cashRounding, validatePhoneNumber } from '@lib/ussd'; +import { translate } from '@i18n/translators'; enum TransferError { + INVALID_RECIPIENT = 'INVALID_RECIPIENT', INVITE_ERROR = 'INVITE_ERROR', TRANSFER_ERROR = "TRANSFER_ERROR", } +export interface TransferContext extends UserContext { + data: { + amount?: number, + recipientEntry?: string, + recipientTag?: string, + recipientAddress?: string, + } +} -export const transferMachine = createMachine({ +export const stateMachine = createMachine({ id: MachineId.TRANSFER, initial: "enteringRecipient", predictableActionArguments: true, @@ -72,7 +80,7 @@ export const transferMachine = createMachine({ on: { BACK: 'mainMenu', TRANSIT: [ - { target: 'validatingRecipient', actions: 'saveEntry' } + { target: 'validatingRecipient', actions: 'saveRecipientEntry' } ] } }, @@ -88,12 +96,22 @@ export const transferMachine = createMachine({ } }, invalidRecipient: { - description: 'Entered phone number is invalid.', + description: 'Entered identifier is invalid.', + on: { + TRANSIT: [ + { target: 'exit', cond: 'isOption9' }, + { target: 'validatingRecipient', cond: 'isValidIdentifier', actions: 'saveRecipientEntry' }, + ] + }, + tags: 'error' + }, + invalidRecipientWithInvite: { + description: 'Entered identifier is invalid.', on: { TRANSIT: [ { target: 'exit', cond: 'isOption9' }, { target: 'invitingRecipient', cond: 'isOption1' }, - { target: 'validatingRecipient', cond: 'isValidPhoneNumber', actions: 'saveEntry' }, + { target: 'validatingRecipient', cond: 'isValidIdentifier', actions: 'saveRecipientEntry' }, ] }, tags: 'error' @@ -146,7 +164,10 @@ export const transferMachine = createMachine({ id: 'validatingRecipient', src: 'validateRecipient', onDone: { target: 'enteringAmount', actions: 'saveValidatedRecipient' }, - onError: { target: 'invalidRecipient', actions: 'updateErrorMessages' } + onError: [ + { target: 'invalidRecipientWithInvite', cond: 'isInvitableRecipientError', actions: 'updateErrorMessages' }, + { target: 'invalidRecipient', actions: 'updateErrorMessages' } + ] }, tags: 'invoked' } @@ -155,7 +176,7 @@ export const transferMachine = createMachine({ { actions: { saveAmount, - saveEntry, + saveRecipientEntry, saveValidatedRecipient, updateErrorMessages }, @@ -165,9 +186,10 @@ export const transferMachine = createMachine({ isOption1, isOption9, isSuccess, + isInvitableRecipientError, isTransferError, isValidAmount, - isValidPhoneNumber, + isValidIdentifier, isValidPin }, services: { @@ -179,10 +201,9 @@ export const transferMachine = createMachine({ }); -async function authorizeTransfer(context: BaseContext, event: any) { - const { input } = event +async function authorizeTransfer(context: TransferContext, event: any) { - await validatePin(context, input) + await validatePin(context, event.input) try{ await initiateTransfer(context) @@ -192,15 +213,15 @@ async function authorizeTransfer(context: BaseContext, event: any) { } } -async function initiateInvite(context: BaseContext) { +async function initiateInvite(context: TransferContext) { const { - data: { transfer }, + data, user: { account: { phone_number } }, ussd: { countryCode } } = context - if (!transfer?.recipient?.entry) { + if (!data.recipientEntry) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Missing recipient entry.") } - const invitee = validatePhoneNumber(countryCode, transfer.recipient.entry) + const invitee = validatePhoneNumber(countryCode, data.recipientEntry) try { console.debug(`Initiating invite to ${invitee} from ${phone_number}`) return { success: true } @@ -209,30 +230,34 @@ async function initiateInvite(context: BaseContext) { } } -function isInviteError(_: BaseContext, event: any) { +function isInvitableRecipientError(_: TransferContext, event: any) { + return event.data.code === TransferError.INVALID_RECIPIENT || event.data.code === BaseMachineError.UNKNOWN_ACCOUNT +} + +function isInviteError(_: TransferContext, event: any) { return event.data.code === TransferError.INVITE_ERROR } -function isTransferError(_: BaseContext, event: any) { +function isTransferError(_: TransferContext, event: any) { return event.data.code === TransferError.TRANSFER_ERROR } -async function initiateTransfer(context: BaseContext) { +async function initiateTransfer(context: TransferContext) { const { - data: { transfer }, + data, user: {account: {address}, vouchers: { active: {address: voucherAddress}}} } = context - if (!transfer?.recipient?.validated || !transfer?.amount) { + if (!data.recipientAddress || !data.amount) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Missing recipient or amount.") } let response; try { response = await custodialTransfer({ - amount: transfer.amount * 1000000, + amount: data.amount * 1000000, from: address, - to: transfer.recipient.validated, + to: data.recipientAddress, voucherAddress: voucherAddress }) } catch (error: any) { @@ -244,9 +269,9 @@ async function initiateTransfer(context: BaseContext) { } try { - await createTracker(context.resources.db, { + await createTracker(context.connections.db, { address: address, - task_type: CustodialTaskType.TRANSFER, + task_type: TaskType.TRANSFER, task_reference: response.result.trackingId }) return { success: true } @@ -255,51 +280,41 @@ async function initiateTransfer(context: BaseContext) { } } -function isValidAmount(context: BaseContext, event: any) { +function isValidAmount(context: TransferContext, event: any) { const amount = Number(event.input) return !isNaN(amount) && amount > 0 && amount <= context.user.vouchers.active.balance } -function saveAmount(context: BaseContext, event: any) { - context.data.transfer = { - ...(context.data.transfer || {}), - amount: cashRounding(event.input) - } +function isValidIdentifier(context: TransferContext, event: any) { + return event.input.length === 6 || event.input.startsWith('0x') || isValidPhoneNumber(context, event) +} + +function saveAmount(context: TransferContext, event: any) { + context.data.amount = cashRounding(event.input) return context } -function saveEntry(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - transfer: { - ...(context.data?.transfer || {}), - recipient: { - ...(context.data?.transfer?.recipient || {}), - entry: event.input - } - } - } +function saveRecipientEntry(context: TransferContext, event: any) { + context.data.recipientEntry = event.input return context } -function saveValidatedRecipient(context: BaseContext, event: any) { - context.data.transfer = { - recipient: { - ...(context.data.transfer?.recipient || {}), - tag : event.data.tag, - validated: event.data.address - } - } +function saveValidatedRecipient(context: TransferContext, event: any) { + context.data.recipientAddress = event.data.address + context.data.recipientTag = event.data.tag return context } -async function validateRecipient(context: BaseContext, event: any) { +async function validateRecipient(context: TransferContext, event: any) { const { input } = event const recipient = await validateTargetUser(context, input) - return { address: recipient.account.address , tag: recipient.tag } + if (!recipient) { + throw new MachineError(TransferError.INVALID_RECIPIENT, "Invalid recipient.") + } + return { address: recipient.account.address, tag: recipient.tag } } -export async function transferTranslations(context: BaseContext, state: string, translator: any) { +async function transferTranslations(context: TransferContext, state: string, translator: any) { const { data, user: { @@ -315,27 +330,23 @@ export async function transferTranslations(context: BaseContext, state: string, return await translate(state, translator, { spendable: balance, symbol }); case 'enteringPin': case 'transferInitiated': - if (!data?.transfer?.amount || !data?.transfer?.recipient?.tag){ - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Missing transfer amount or recipient tag.") - } return await translate(state, translator, { - amount: data.transfer.amount, - recipient: data.transfer.recipient.tag, + amount: data?.amount, + recipient: data?.recipientTag, sender: tag, symbol: symbol, }); case 'invalidRecipient': - if(!data?.transfer?.recipient?.entry){ - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Missing recipient entry.") - } - return await translate(state, translator, { recipient: data.transfer.recipient.entry }); + return await translate(state, translator, { recipient: data?.recipientEntry}); case 'inviteError': case 'inviteSuccess': - if(!data?.transfer?.recipient?.entry){ - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Missing recipient entry.") - } - return await translate(state, translator, { invitee: data.transfer.recipient.entry }); + return await translate(state, translator, { invitee: data?.recipientEntry }); default: return await translate(state, translator); } } + +export const transferMachine = { + stateMachine, + translate: transferTranslations +} diff --git a/src/machines/utils.ts b/src/machines/utils.ts index ec0283f..4f11d66 100644 --- a/src/machines/utils.ts +++ b/src/machines/utils.ts @@ -2,34 +2,21 @@ import { PostgresDb } from '@fastify/postgres'; import { GraphQLClient } from 'graphql-request'; import { Provider } from 'ethers'; import { Redis as RedisClient } from 'ioredis'; -import { CountryCode } from 'libphonenumber-js'; -import { ActiveVoucher } from '@lib/ussd/voucher'; -import { Account } from '@db/models/account'; -import { Session } from '@lib/ussd/session'; -import { supportedLanguages } from '@lib/ussd/utils'; -import { Gender, GraphUser } from '@lib/graph/user'; -import { fallbackLanguage, tHelpers } from '@i18n/translators'; -import { sanitizePhoneNumber } from '@utils/phoneNumber'; -import { MachineError } from '@lib/errors'; -import { Cache } from '@utils/redis'; -import { Transaction } from '@machines/statement'; -import { GraphAccount } from '@lib/graph/account'; -import { Marketplace } from '@lib/graph/marketplace'; +import { getAddressFromTill, getAddressFromVpa, Ussd, validatePhoneNumber } from '@lib/ussd'; import { logger } from '@/app'; -import { Locales } from '@i18n/i18n-types'; - -enum BaseError { - INVALID_PHONE_NUMBER = "INVALID_PHONE_NUMBER", - SELF_INTERACTION = "SELF_INTERACTION", - UNKNOWN_ACCOUNT = "UNKNOWN_ACCOUNT", -} +import { User, UserService } from '@services/user'; +import { BaseMachineError, MachineError } from '@lib/errors'; +import { CountryCode } from 'libphonenumber-js'; +import { translate } from '@i18n/translators'; +import { StateMachine } from 'xstate'; +import { getPhoneNumberFromAddress } from '@services/account'; export enum MachineId { AUTH = "auth", BALANCES = "balances", LANGUAGES = "languages", MAIN = "main", - PINS = "pins", + PIN_MANAGEMENT = "pins", PROFILE = "profile", REGISTRATION = "registration", SETTINGS = "settings", @@ -39,112 +26,42 @@ export enum MachineId { VOUCHER = "voucher", } - -interface ContextData { - communityBalance: string, - guardians: { - entry?: { - toAdd?: string, - toRemove?: string - }, - loaded?: string[], - validated?: { - toAdd?: string, - toRemove?: string - } - }, - languages: { - selected: Locales - }, - personal_information?: { - family_name?: string, - gender?: Gender, - given_names?: string, - location_name?: string, - year_of_birth?: number, - }, - pins: { - initial?: string, - wards?: { - entry?: string, - validated?: string - } - }, - marketplace: string, - statement: string[], - transfer: { - amount?: number, - recipient?: { - entry?: string, - tag?: string - validated?: string - } - }, - vouchers: { - balances?: string[], - held?: string[], - selected?: string - info?: { - contact?: string; - description?: string; - location?: string; - name?: string; - symbol?: string; - } +export type Connections = { + db: PostgresDb, + graphql: GraphQLClient, + provider: Provider, + redis: { + ephemeral: RedisClient, + persistent: RedisClient } } -export interface BaseContext { - data: Partial - errorMessages?: string[], - resources: Resources, - session?: Session, - user: User - ussd: Ussd -} +export type MachineEvent = + | { type: "BACK" } + | { type: "RETRY", feedback: string } + | { type: "TRANSIT", input: string } -interface Graph { - account: Pick - marketplace?: Pick - user: Partial +export interface BaseContext { + connections: Connections, + data: Record, + errorMessages: string[], + ussd: Ussd, } -export interface User { - account: Account - graph: Graph - guardians?: string[] - tag: string - transactions?: Transaction[] - vouchers: { - active: ActiveVoucher - held?: ActiveVoucher[] - } +export interface MachineInterface { + stateMachine: StateMachine, + translate: (context: any, state: string, translator: any) => Promise, } -export interface Resources { - db: PostgresDb - e_redis: RedisClient - graphql: GraphQLClient - p_redis: RedisClient - provider: Provider - +export interface MachineServiceInterface { + stop: () => void, + transition: (event: MachineEvent) => void, } -export interface Ussd { - countryCode: CountryCode, - input: string, - phoneNumber: string, - requestId: string, - responseContentType: string, - serviceCode: string, +export interface UserContext extends BaseContext { + user: User } - -export type BaseEvent = - | { type: "BACK" } - | { type: "RETRY", feedback: string } - | { type: "TRANSIT", input: string } - export function clearErrorMessages (context: BaseContext, event: any) { return { ...context, @@ -173,10 +90,13 @@ export const isOption00 = generateOptionChecker('00'); export const isOption11 = generateOptionChecker('11'); export const isOption22 = generateOptionChecker('22'); - -export function getLanguage(input: string): Locales { - const index = parseInt(input) - 1; - return Object.keys(supportedLanguages)[index] as Locales; +export async function intermediateMachineTranslations(context: UserContext, state: string, translator: any) { + const { user: { vouchers: { active: { balance, symbol } } } } = context + if (state === "mainMenu"){ + return await translate(state, translator, { balance: balance, symbol: symbol }) + } else { + return await translate(state, translator) + } } export function isValidPhoneNumber(context: BaseContext, event: any) { @@ -184,42 +104,10 @@ export function isValidPhoneNumber(context: BaseContext, event: any) { return validatePhoneNumber(countryCode, event.input)[0] === '+'; } -export function isSuccess(context: BaseContext, event: any) { +export function isSuccess(_: any, event: any) { return event.data.success } -export async function languageOptions () { - const languages = Object.values(supportedLanguages) - .map((language, index) => `${index + 1}. ${language}`) - const placeholder = tHelpers("noMoreLanguageOptions", fallbackLanguage()) - return await menuPages(languages, placeholder) -} - -export async function menuPages(list: string[], placeholder: string): Promise { - const pages = []; - for (let i = 0; i < list.length; i += 3) { - pages.push(list.slice(i, i + 3)); - } - while (pages.length < 3) { - pages.push([]); - } - return pages.map((group) => { - if (group.length === 0) { - return placeholder; - } else { - return group.join("\n"); - } - }); -} - -export async function translate(state: string, translator:any, data?: Record){ - if (data) { - return translator[state](data) - } else { - return translator[state]() - } -} - export function updateErrorMessages (context: BaseContext, event: any) { const errorMessages = context.errorMessages || [] const { message } = event.data @@ -229,32 +117,38 @@ export function updateErrorMessages (context: BaseContext, event: any) { return context } -export function validatePhoneNumber(countryCode: CountryCode, phoneNumber: string, ) { - try { - return sanitizePhoneNumber(phoneNumber, countryCode); - } catch (error) { - throw new MachineError(BaseError.INVALID_PHONE_NUMBER, "Invalid phone number.") - } -} - -export async function validateUser(countryCode: CountryCode, phoneNumber: string, redis: RedisClient): Promise { +export async function validateUser(countryCode: CountryCode, phoneNumber: string, redis: RedisClient){ const key = validatePhoneNumber(countryCode, phoneNumber) - const cache = new Cache(redis, key) - const user = await cache.getJSON() + const user = new UserService(key, redis).get() if (!user) { - throw new MachineError(BaseError.UNKNOWN_ACCOUNT, `Account not found for: ${key}`) + throw new MachineError(BaseMachineError.UNKNOWN_ACCOUNT, `Account not found for phone number: ${key}.`) } return user } -export async function validateTargetUser(context: BaseContext, input: string) { - const { user, resources: { p_redis }, ussd: { countryCode } } = context +export async function validateTargetUser(context: UserContext, input: string) { + const { user, connections: { db, graphql, redis }, ussd: { countryCode } } = context + + let address, phoneNumber; + if(input.length === 6 || input.startsWith('0x')){ + address = await (input.startsWith('0x') ? getAddressFromVpa : getAddressFromTill)(graphql, redis.persistent, input) + if(!address) { + throw new MachineError(BaseMachineError.UNKNOWN_TILL_OR_VPA, `Account not found for till or vpa: ${input}.`) + } + phoneNumber = await getPhoneNumberFromAddress(address, db, redis.persistent) + if (!phoneNumber) { + throw new MachineError(BaseMachineError.UNKNOWN_ADDRESS, `Account not found for address: ${address}.`) + } + } else { + phoneNumber = input + } - const targetUser = await validateUser(countryCode, input, p_redis) + const targetUser = await validateUser(countryCode, phoneNumber, redis.persistent) - if (user?.account.phone_number === targetUser.account.phone_number) { - throw new MachineError(BaseError.SELF_INTERACTION, "Cannot interact with self.") + if (user?.account.phone_number === targetUser?.account?.phone_number) { + throw new MachineError(BaseMachineError.SELF_INTERACTION, "Cannot interact with self.") } return targetUser -} \ No newline at end of file +} + diff --git a/src/machines/voucher.ts b/src/machines/voucher.ts index eec92a1..36d4b8a 100644 --- a/src/machines/voucher.ts +++ b/src/machines/voucher.ts @@ -1,7 +1,5 @@ import { createMachine, raise } from 'xstate'; import { - BaseContext, - BaseEvent, isOption00, isOption1, isOption11, @@ -9,26 +7,43 @@ import { isOption22, isOption9, isSuccess, + MachineEvent, MachineId, - menuPages, - translate, - updateErrorMessages + updateErrorMessages, + UserContext } from '@machines/utils'; -import { ActiveVoucher } from '@lib/ussd/voucher'; import { isBlocked, validatePin } from '@machines/auth'; -import { Cache } from '@utils/redis'; -import { Voucher } from '@lib/graph/voucher'; -import { Transaction, TransactionType } from '@machines/statement'; -import { tHelpers } from '@i18n/translators'; +import { tHelpers, translate } from '@i18n/translators'; import { ContextError, MachineError } from '@lib/errors'; import { Locales } from '@i18n/i18n-types'; +import { CachedVoucher, getVoucherByAddress, handleResults, menuPages } from '@lib/ussd'; +import { Transaction, TransactionType } from '@services/transfer'; +import { UserService } from '@services/user'; enum VouchersError { SET_FAILED = "SET_FAILED", } +interface VoucherInfo { + description: string, + location: string, + name: string, + symbol: string, +} + +export interface VouchersContext extends UserContext { + data: { + heldVouchers: string[]; + heldVouchersInfo: { + [key: string]: VoucherInfo + }; + selectedVoucher: string; + voucherBalances: string[]; + voucherInformation: VoucherInfo + } +} -export const voucherMachine = createMachine({ +export const stateMachine = createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOnQFcAXbAewCdcAvAqAZTABsxNLcb8AxBH5gSBAG40A1qIrV6TFuy48++ANoAGALqJQABxqxcvfnpAAPRAEZrAVgDsJO5tcA2F7YAcjuwBoQAE9ENwBmACYSBxdNRy948MSAXySAtCw8QlI5WgZmfDZOblNBMDo6ehJ9DnRKADN6VDIqXMUC5WK1LV0kEENjEvMrBFtHZ1dND01vXwDghFC3AE5xidDXBwAWTYcltxS0jBwCYmb5PKUi1X4BMoq6Kpr6xrPW-MKVEvVrHoMjEzUQxs9icMUmnmsPmic0Q4U0mxImnCoU29nCWwc1mimwOIHSxyyrwU7w611K5Uq1VqDToTRyxMuny64V+fX+g16w1GoImUxm0KCiAcbkiYLcDi82zsXixuPxmVOEFwsCpgQAajRyDgygBJfANAQAIQAggBhADS3XM-QBZk5iEWSycyM2dlCdnCSy9oSWMJG62sJCW1jcyx2mgiky8cqOCtIYHwlDKLAACgQBAAVABKxoAcqwdRmrb0bRzQMNPaEvCRXRFwo5Nuj4m4-UtQoHoq4llHHPZo6k8bGTvHE8mCmnBNm8wWiz9rezAfaEOFrCiSCuEt2InZxX7NvEomD4TKIl5wjiB-LhyQ6rg6LBKBqtdgyuxKEazZadPOBovy7DJicJY3RXBxI2Rc8-WsVFA02UJ4NRH19yROwYwya9b3vR9NW1Og30zHN80LYs-l-O1-2XQCgxArFwIiTYoPbOway8aZu3CNxbCdVDLyHQlMIfJ9cPwqciNnVlSz-SwbA4wNoMmEUvF2btQygtxNk0INXThPZ3U0MDQjQglTgE7Dn1fMB31Emd1BZH9bXwIFKPFaiIlouEIIYwURg4hEllYkN62sYD-IcIy4xvO9BJwl88Msgjp2I0IJIXcjpOcoCaLAjz6KghxNBWOwQ1DGUz1iJZwnC68JHQDhcAgCcBCzABRbMAE0SLZMjHKXOEXOAtzsqRXLvOFQMJn0-cJQ3fZePQwkarqiBSRKJrWqzDrvxLVKeooxJ1KiDx8vWeDQkxNS7BWaZQw07Z4Vmw55tODgaHQJUCgACU4CAhNi2AhBEMR8EkGQSCvQkXrelgvo4H6YrKWAEAkGhMFqLodE6yS0orUKSB8YCV1O4CHAcKCiqceENhRNwI2DQy5uM0hIfeqAYbh8z71uCkHipZ5aTBvjntelm2d+hGkeBlG0f4bpMZ2pz3TA9cTyWTZlmDVcoM2YC8bA26XCUiV6cexmSGZlgxboPUDWEQggZB0RzYKS3rZoOXuoVxxQmV1dVfV2xQig6DA2WaZYiNk6LxNiKnagF39RoLn7keakXlj+OGndhzPaV8IVbVr0A6g48ojOiM4Pu7XrCqwlYG4fh2eE+LrOIrbSOzpduVWcFpkhWZvNsFdxkWFCwJDMKGYiuvMAby2RMImy522j2lzVpxhR3D1gq8YNdhbAfhQRDiSoKtsd5XGvTmn2f4biqyF+Iuzl47iiu7FCEoX8Afllg7SzrO0MDgL6T2vNffAjdYrz0SkWZK9kyzpTfryD+-d5jWH0uNCMYQx7rBcA9QcT1SBgIgRZe+0D1CbBSivV+IJu58j7gKVBXh1jrlDNKJh6wwhVkvoQyyrAtSYDgP9E0Fos7wIrFRAaoE6KQW8upZimgmGom0lCJS3CSB10oHwzAAjYD-RbkWNuXUX7pT6plQa0ivLzEugiaUKJtYcUlCKY2+DTbUDvMQu+CUxKiKksMOCCJ9Ini9h6WmljEAqWcMiIq8FlgomCmotxdAPFQO8UvduYjED+MRBKVcwS4RISgjKBEwoboijsBpBRPFo7XkSck5uD8ixP3Sb4zJa5Am5LAiEgp3kZQ1i9MGHe48kTxASXgJJc96lkNgc-DJCAsntMVu6fJqsoLAXGsVEKyIPA+DUZIDmABZBM5APwiMMVjXaJiz41hAvWLYTYvD71QUiSIxUgEen3AXKOLiIp7Nwoc-Axz9E+OxrCK5tZEgNnuY8mwjZIihkAQZCI6kJ7VMJL82K-zAUNO+JQ4xOMqzXLrJCiUDyg5wRrOPFwER-IuCWCkAc+AaAQDgOYcGxA4EtIQAAWmhdy5i-SBWCubGorAM9yCJkNC9TAMgIAcpBQsHSJAPBhAeeKMIsQwkIB3t7eFmIdysSrHBEVLQGTtCuPA85TkwKghyaxJ08ISak1kWeCl4o85l2lOeNRSoVQ1HVLfV2cqLnDCrNMQ80xHAkzcEpX0A9fJKvhZdNBCj1hfLZSOJMDBxwECDU5c8WwoirhFDsNW6J0StjQYiCYl11LQX3M49NJAwAWBMLm3qjY3CErhBsDSyI-TSk0pwxs-lclMKqd8jCUUzJN0oG21+R0SBVk9JMc8TDQIXU7csLeG4JTChRROhawNar1QnHOhBbp+VumCiGIqaswh+kxKKCYQDVZokmGoxa9UVpSUte2jiXbn29sDgfZYSrirwVAk4htgsmbC1ds1bmZ6Q0BXDSCKNMbi5qyDGEHcWwzqsXHY25mAB5fQJRYAIfuEhh0KHOxoeFBhge2sAn9JAk448ajY6i1vvAGZnK6yFTArEPO0xFh2C-qgnY1YURenUmECpEpOPCwtgGhO1GFUFSVTuKsgD1XlKDvCBN8LFhRNVWojABBMXqcjCsZVOm1U4M1f0kgryt4IWjfuxtRCJmzr4-K3YmkDXyZvSTHceVWKImKmgjwIZ6JqI0ZR+g6n7DulctemLd7gPzA8uubWexe1nm2PF3h-DBHJdDJpTB4mB3nnKdYP0NNCoTFdJdPOYZRnuJ8+pxsqzXCHVYY4MItFPMwZIOisoVm-PBthMW5wIJkQ4KNhJmwZ15Hj1DeUkmat6VJCAA */ id: MachineId.VOUCHER, initial: "voucherMenu", @@ -208,7 +223,7 @@ export const voucherMachine = createMachine({ }) -async function authorizeSelection(context: BaseContext, event: any) { +async function authorizeSelection(context: VouchersContext, event: any) { const { input } = event await validatePin(context, input) @@ -223,27 +238,46 @@ async function authorizeSelection(context: BaseContext, event: any) { } -function isSetError(context: BaseContext, event: any) { +function isSetError(context: VouchersContext, event: any) { return event.data.code === VouchersError.SET_FAILED } -async function loadHeldVouchers(context: BaseContext) { - const { user } = context; - const info = await loadVoucherInfo(context); - if (!user.vouchers.active) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Active voucher is missing from context."); - } - const held = user?.vouchers?.held || []; - const transactions = user?.transactions || []; - const formatted = await formatVouchers(user.vouchers.active, held, user.account.language, transactions); - return { held: formatted, info }; +async function loadHeldVouchers(context: VouchersContext) { + const { connections: { graphql, redis }, user: { account, statement, vouchers: { active, held } } } = context + const voucherInfoPromises = held.map(async (voucher) => { + const info = await getVoucherByAddress(voucher.address, graphql, redis.persistent) + if(info){ + return { + description: info.voucher_description, + location: info.location_name, + name: info.voucher_name, + symbol: info.symbol + } + } + return null + }) + const results = await Promise.allSettled(voucherInfoPromises) + const voucherInfo = handleResults(results).filter(result => result !== null) + let heldVouchersInfo: { [key: string]: VoucherInfo } = {} + voucherInfo.forEach(info => { + heldVouchersInfo[info.symbol] = info + }) + const heldVouchers = await formatVouchers(active, held, account.language, statement || []); + return { heldVouchers, heldVouchersInfo } } -async function loadVoucherInfo(context: BaseContext) { - const { resources: { p_redis }, user: { vouchers: {active: { address } } } } = context; +async function loadVoucherInfo(context: VouchersContext) { + const { + connections: { + graphql, + redis + }, + user: { + vouchers: { active: { address } } + } + } = context; - const cache = new Cache(p_redis, address); - const voucher = await cache.getJSON(); + const voucher = await getVoucherByAddress(address, graphql, redis.persistent) if (!voucher) { throw new Error("Could not load voucher voucher."); @@ -257,11 +291,7 @@ async function loadVoucherInfo(context: BaseContext) { }; } -async function formatVouchers(active: ActiveVoucher, held: ActiveVoucher[], language: Locales, transactions: Transaction[]) { - - if (!transactions) { - transactions = []; - } +async function formatVouchers(active: CachedVoucher, held: CachedVoucher[], language: Locales, transactions: Transaction[]) { // get credits and debits in one loop let credits = []; let debits = []; @@ -305,141 +335,122 @@ async function formatVouchers(active: ActiveVoucher, held: ActiveVoucher[], lang // format the vouchers const formattedVouchers = orderedHeld .map((voucher, index) => `${index + 1}. ${voucher?.symbol} ${voucher?.balance.toFixed(2)}`); - const placeholder = tHelpers("noMoreTransactions", language) + const placeholder = tHelpers("noMoreVouchers", language) return await menuPages(formattedVouchers, placeholder) } -function isValidVoucherOption(context: BaseContext, event: any) { - const { vouchers } = context.data; +function isValidVoucherOption(context: VouchersContext, event: any) { + const { heldVouchers } = context.data; + const input = event.input.toLowerCase(); - if (!vouchers?.held) { - return false; - } + // Use for loop instead of find to break early when a match is found + for (const voucher of heldVouchers) { + // Check if the voucher can be split and do the split + if (voucher.includes('. ')) { + const [index, symbol] = voucher.split('. '); - const input = event.input.toLowerCase(); - const selection = vouchers.held.find((voucher) => { - const [index, symbol] = voucher.split('. '); - return index === input || symbol.split(" ")[0].toLowerCase() === input; - }); + // Check if the index or symbol matches the input and return true if found + if (index === input || (symbol.includes(" ") && symbol.split(" ")[0].toLowerCase() === input)) { + return true; + } + } + } - return Boolean(selection); + // Return false if no match is found + return false; } +async function setActiveVoucher(context: VouchersContext) { + const { + data, + connections: { + redis + }, + user: { + account: { phone_number }, + vouchers + } + } = context -async function setActiveVoucher(context: BaseContext) { - const { data, resources: { p_redis }, user: { account: { phone_number }, vouchers } } = context - - if (!data.vouchers?.selected) { + if (!data.selectedVoucher) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Selected voucher is missing from context."); } - const selected = data.vouchers.selected - if(!vouchers?.held) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Held vouchers are missing from context."); - } - const held = vouchers.held - - const voucher = held.find(v => v.symbol === selected.toUpperCase()) - const cache = new Cache(p_redis, phone_number) - await cache.updateJSON({ - vouchers: { - active: voucher - } - }) + const voucher = vouchers.held.find(v => v.symbol === data.selectedVoucher?.toUpperCase()) + await new UserService(phone_number, redis.persistent).update({ vouchers: { active: voucher } }) } -function saveHeldVouchers(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - vouchers: { - ...(context.data?.vouchers || {}), - held: event.data.held, - info: event.data.info, - } - } +function saveHeldVouchers(context: VouchersContext, event: any) { + context.data.heldVouchers = event.data.heldVouchers; + context.data.heldVouchersInfo = event.data.heldVouchersInfo; return context; } -function saveVoucherInfo(context: BaseContext, event: any) { - context.data = { - ...(context.data || {}), - vouchers: { - ...(context.data?.vouchers || {}), - info: event.data, - } - } +function saveVoucherInfo(context: VouchersContext, event: any) { + context.data.voucherInformation = event.data; return context; } -function saveVoucherSelection(context: BaseContext, event: any) { +function saveVoucherSelection(context: VouchersContext, event: any) { const input = event.input.toLowerCase(); - if (!context.data.vouchers?.held) { + if (!context.data.heldVouchers) { throw new MachineError(ContextError.MALFORMED_CONTEXT, "Held vouchers are missing from context data."); } - const vouchers = context.data.vouchers.held; - - const selectedVoucher = vouchers.find((voucher) => { + const selectedVoucher = context.data.heldVouchers.find((voucher) => { const [index, symbol] = voucher.split('. '); return index === input || symbol.split(" ")[0].toLowerCase() === input; }); if (selectedVoucher) { const [_, symbol] = selectedVoucher.split('. '); - context.data.vouchers = { - ...context.data.vouchers, - selected: symbol.split(" ")[0], - }; + context.data.selectedVoucher = symbol.split(" ")[0] } return context; } - -function updateActiveVoucher(context: BaseContext, event: any) { +function updateActiveVoucher(context: VouchersContext, event: any) { context.user.vouchers.active = event.data; return context } -export async function voucherTranslations(context: BaseContext, state: string, translator: any) { +async function voucherTranslations(context: VouchersContext, state: string, translator: any) { const { data, user: { vouchers } } = context; switch (state) { case "firstVoucherSet": - if(!data.vouchers?.held) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Held vouchers are missing from context data."); - } - return await translate(state, translator, { vouchers: data.vouchers.held[0] }); + return await translate(state, translator, { vouchers: data.heldVouchers[0] }); case "secondVoucherSet": - if(!data.vouchers?.held) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Held vouchers are missing from context data."); - } - return await translate(state, translator, { vouchers: data.vouchers.held[1] }); + return await translate(state, translator, { vouchers: data.heldVouchers[1] }); case "thirdVoucherSet": - if(!data.vouchers?.held) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Held vouchers are missing from context data."); - } - return await translate(state, translator, { vouchers: data.vouchers.held[2] }); + return await translate(state, translator, { vouchers: data.heldVouchers[2] }); case "enteringPin": + const selectedVoucher = data.heldVouchersInfo[data.selectedVoucher]; + return await translate(state, translator, { + description: selectedVoucher?.description, + location: selectedVoucher?.location, + name: selectedVoucher?.name, + symbol: data.selectedVoucher + }) case "displayVoucherInfo": - if(!data.vouchers?.info) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Voucher info is missing from context data."); - } return await translate(state, translator, { - description: data.vouchers.info.description, - location: data.vouchers.info.location, - name: data.vouchers.info.name, + description: data.voucherInformation?.description, + location: data.voucherInformation?.location, + name: data.voucherInformation?.name, symbol: vouchers.active.symbol }); case "setSuccess": - if (!data.vouchers?.selected) { - throw new MachineError(ContextError.MALFORMED_CONTEXT, "Selected voucher is missing from context data."); - } - return await translate(state, translator, { symbol: data.vouchers.selected }); + return await translate(state, translator, { symbol: data.selectedVoucher }); case "mainMenu": return await translate(state, translator, { balance: vouchers.active.balance, symbol: vouchers.active.symbol }); default: return await translate(state, translator); } } + +export const voucherMachine = { + stateMachine, + translate: voucherTranslations +}