Skip to content

Commit

Permalink
refactor(machines): Implements generic machine pattern.
Browse files Browse the repository at this point in the history
  • Loading branch information
Mango Habanero committed Apr 30, 2023
1 parent 40e3507 commit 860707c
Show file tree
Hide file tree
Showing 14 changed files with 840 additions and 967 deletions.
98 changes: 48 additions & 50 deletions src/machines/auth.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -14,8 +22,13 @@ enum AuthErrors {
UNAUTHORIZED = "UNAUTHORIZED"
}

export interface AuthContext extends UserContext {
data: {
initialPin?: string,
}
}

export const authMachine = createMachine<BaseContext, BaseEvent>({
const stateMachine = createMachine<AuthContext, MachineEvent>({
id: MachineId.AUTH,
initial: "authenticating",
predictableActionArguments: true,
Expand Down Expand Up @@ -116,103 +129,88 @@ export const authMachine = createMachine<BaseContext, BaseEvent>({
}
})

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) {
throw new MachineError(AuthErrors.HASH_ERROR, "Invalid pin format.");
}
}

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
}
52 changes: 29 additions & 23 deletions src/machines/balances.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
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 {
FETCH_ERROR = "FETCH_ERROR",
LOAD_ERROR = "LOAD_ERROR",
}

export const balancesMachine = createMachine<BaseContext, BaseEvent>({
export interface BalancesContext extends UserContext {
data: {
communityBalance: number,
}
}

export const stateMachine = createMachine<BalancesContext, MachineEvent>({
/** @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",
Expand Down Expand Up @@ -162,7 +167,7 @@ export const balancesMachine = createMachine<BaseContext, BaseEvent>({
}
})

async function loadAccountBalance(context: BaseContext, event: any) {
async function loadAccountBalance(context: BalancesContext, event: any) {
const { user: { vouchers: { active: { balance } } } } = context;
const { input } = event;

Expand All @@ -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<Voucher>(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":
Expand All @@ -223,3 +224,8 @@ export async function balancesTranslations(context: BaseContext, state: string,
return translate(state, translator);
}
}

export const balancesMachine: MachineInterface = {
stateMachine,
translate: balancesTranslations
}
Loading

0 comments on commit 860707c

Please sign in to comment.