diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 539e09dad..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - } -} diff --git a/packages/api/src/controllers/v2/microcredit.ts b/packages/api/src/controllers/v2/microcredit.ts deleted file mode 100644 index d61c32b1d..000000000 --- a/packages/api/src/controllers/v2/microcredit.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { services } from '@impactmarket/core'; -import { Request, Response } from 'express'; -import { standardResponse } from '../../utils/api'; - -class MicrocreditController { - microcreditService = new services.MicrocreditService(); - - getGlobalData = async (req: Request, res: Response): Promise => { - this.microcreditService - .getGlobalData() - .then((r) => standardResponse(res, 200, true, r)) - .catch((e) => standardResponse(res, 400, false, '', { error: e })); - }; -} - -export default MicrocreditController; \ No newline at end of file diff --git a/packages/api/src/controllers/v2/microcredit/list.ts b/packages/api/src/controllers/v2/microcredit/list.ts new file mode 100644 index 000000000..b8120c3ae --- /dev/null +++ b/packages/api/src/controllers/v2/microcredit/list.ts @@ -0,0 +1,36 @@ +import { services } from '@impactmarket/core'; +import { Response } from 'express'; + +import { standardResponse } from '../../../utils/api'; +import { ListBorrowersRequestSchema } from '../../../validators/microcredit'; +import { ValidatedRequest } from '../../../utils/queryValidator'; +import { RequestWithUser } from '../../../middlewares/core'; + +class MicroCreditController { + private microCreditService: services.MicroCredit.List; + constructor() { + this.microCreditService = new services.MicroCredit.List(); + } + + // list borrowers using a loan manager account + listBorrowers = ( + req: RequestWithUser & ValidatedRequest, + res: Response + ) => { + if (req.user === undefined) { + standardResponse(res, 400, false, '', { + error: { + name: 'USER_NOT_FOUND', + message: 'User not identified!', + }, + }); + return; + } + this.microCreditService + .listBorrowers({ ...req.query, addedBy: req.user.address }) + .then((r) => standardResponse(res, 200, true, r)) + .catch((e) => standardResponse(res, 400, false, '', { error: e })); + }; +} + +export { MicroCreditController }; diff --git a/packages/api/src/controllers/v2/protocol/index.ts b/packages/api/src/controllers/v2/protocol/index.ts new file mode 100644 index 000000000..35755055f --- /dev/null +++ b/packages/api/src/controllers/v2/protocol/index.ts @@ -0,0 +1,16 @@ +import { services } from '@impactmarket/core'; +import { Request, Response } from 'express'; +import { standardResponse } from '../../../utils/api'; + +class ProtocolController { + protocolService = new services.Protocol(); + + getMicroCreditData = async (req: Request, res: Response): Promise => { + this.protocolService + .getMicroCreditData() + .then((r) => standardResponse(res, 200, true, r)) + .catch((e) => standardResponse(res, 400, false, '', { error: e })); + }; +} + +export default ProtocolController; diff --git a/packages/api/src/routes/v2/index.ts b/packages/api/src/routes/v2/index.ts index 870856647..9557ab6b0 100644 --- a/packages/api/src/routes/v2/index.ts +++ b/packages/api/src/routes/v2/index.ts @@ -9,6 +9,7 @@ import learnAndEarn from './learnAndEarn'; import story from './story'; import user from './user'; import microcredit from './microcredit'; +import protocol from './protocol'; export default (): Router => { const app = Router(); @@ -21,6 +22,7 @@ export default (): Router => { global(app); attestation(app); microcredit(app); + protocol(app); return app; }; diff --git a/packages/api/src/routes/v2/microcredit.ts b/packages/api/src/routes/v2/microcredit.ts deleted file mode 100644 index 8d214d037..000000000 --- a/packages/api/src/routes/v2/microcredit.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Router } from 'express'; - -import MicrocreditController from '../../controllers/v2/microcredit'; -import { cache } from '../../middlewares/cache-redis'; -import { cacheIntervals } from '../../utils/api'; - -export default (app: Router): void => { - const microcreditController = new MicrocreditController(); - const route = Router(); - app.use('/microcredit', route); - - /** - * @swagger - * - * /microcredit/global: - * get: - * tags: - * - "microcredit" - * summary: "Get Glboal Data" - * responses: - * "200": - * description: OK - * security: - * - api_auth: - * - "write:modify": - */ - route.get( - '/global', - cache(cacheIntervals.oneHour), - microcreditController.getGlobalData - ); -}; \ No newline at end of file diff --git a/packages/api/src/routes/v2/microcredit/index.ts b/packages/api/src/routes/v2/microcredit/index.ts new file mode 100644 index 000000000..33bbac235 --- /dev/null +++ b/packages/api/src/routes/v2/microcredit/index.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; + +import list from './list'; + +export default (app: Router): void => { + const route = Router(); + + app.use('/microcredit', route); + + list(route); +}; diff --git a/packages/api/src/routes/v2/microcredit/list.ts b/packages/api/src/routes/v2/microcredit/list.ts new file mode 100644 index 000000000..11e42e3c0 --- /dev/null +++ b/packages/api/src/routes/v2/microcredit/list.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; + +import { MicroCreditController } from '../../../controllers/v2/microcredit/list'; +import { listBorrowersValidator } from '../../../validators/microcredit'; +import { authenticateToken } from '../../../middlewares'; + +export default (route: Router): void => { + const controller = new MicroCreditController(); + + /** + * @swagger + * + * /microcredit/borrowers: + * get: + * tags: + * - "microcredit" + * summary: "Get List of Borrowers by manager" + * responses: + * "200": + * description: OK + * security: + * - api_auth: + * - "write:modify": + */ + route.get( + '/borrowers/:query?', + authenticateToken, + listBorrowersValidator, + controller.listBorrowers + ); +}; diff --git a/packages/api/src/routes/v2/protocol/index.ts b/packages/api/src/routes/v2/protocol/index.ts new file mode 100644 index 000000000..3bd1d2e5a --- /dev/null +++ b/packages/api/src/routes/v2/protocol/index.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; + +import microcredit from './microcredit'; + +export default (app: Router): void => { + const route = Router(); + app.use('/protocol', route); + + microcredit(route); +}; diff --git a/packages/api/src/routes/v2/protocol/microcredit.ts b/packages/api/src/routes/v2/protocol/microcredit.ts new file mode 100644 index 000000000..849556bc7 --- /dev/null +++ b/packages/api/src/routes/v2/protocol/microcredit.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; + +import ProtocolController from '../../../controllers/v2/protocol'; +import { cache } from '../../../middlewares/cache-redis'; +import { cacheIntervals } from '../../../utils/api'; + +export default (route: Router): void => { + const protocolController = new ProtocolController(); + + /** + * @swagger + * + * /protocol/microcredit: + * get: + * tags: + * - "protocol" + * summary: "Get Global MicroCredit Data" + * responses: + * "200": + * description: OK + */ + route.get( + '/microcredit', + cache(cacheIntervals.oneHour), + protocolController.getMicroCreditData + ); +}; diff --git a/packages/api/src/utils/queryValidator.ts b/packages/api/src/utils/queryValidator.ts new file mode 100644 index 000000000..fdee0fe45 --- /dev/null +++ b/packages/api/src/utils/queryValidator.ts @@ -0,0 +1,244 @@ +import * as Joi from 'joi'; +import * as express from 'express'; +import { IncomingHttpHeaders } from 'http'; +import { ParsedQs } from 'qs'; + +/** + * These are the named properties on an express.Request that this module can + * validate, e.g "body" or "query" + */ +export enum ContainerTypes { + Body = 'body', + Query = 'query', + Headers = 'headers', + Fields = 'fields', + Params = 'params', +} + +/** + * Use this in you express error handler if you've set *passError* to true + * when calling *createValidator* + */ +export type ExpressJoiError = Joi.ValidationResult & { + type: ContainerTypes; +}; + +/** + * A schema that developers should extend to strongly type the properties + * (query, body, etc.) of incoming express.Request passed to a request handler. + */ +export type ValidatedRequestSchema = Record; + +/** + * Use this in conjunction with *ValidatedRequestSchema* instead of + * express.Request for route handlers. This ensures *req.query*, + * *req.body* and others are strongly typed using your + * *ValidatedRequestSchema* + */ +export interface ValidatedRequest + extends express.Request { + body: T[ContainerTypes.Body]; + query: T[ContainerTypes.Query] & ParsedQs; + headers: T[ContainerTypes.Headers]; + params: T[ContainerTypes.Params]; +} + +/** + * Use this in conjunction with *ValidatedRequestSchema* instead of + * express.Request for route handlers. This ensures *req.query*, + * *req.body* and others are strongly typed using your *ValidatedRequestSchema* + * + * This will also allow you to access the original body, params, etc. as they + * were before validation. + */ +export interface ValidatedRequestWithRawInputsAndFields< + T extends ValidatedRequestSchema +> extends express.Request { + body: T[ContainerTypes.Body]; + query: T[ContainerTypes.Query]; + headers: T[ContainerTypes.Headers]; + params: T[ContainerTypes.Params]; + fields: T[ContainerTypes.Fields]; + originalBody: any; + originalQuery: any; + originalHeaders: IncomingHttpHeaders; + originalParams: any; + originalFields: any; +} + +/** + * Configuration options supported by *createValidator(config)* + */ +export interface ExpressJoiConfig { + statusCode?: number; + passError?: boolean; + joi?: object; +} + +/** + * Configuration options supported by middleware, e.g *validator.body(config)* + */ +export interface ExpressJoiContainerConfig { + joi?: Joi.ValidationOptions; + statusCode?: number; + passError?: boolean; +} + +/** + * A validator instance that can be used to generate middleware. Is returned by + * calling *createValidator* + */ +export interface ExpressJoiInstance { + body( + schema: Joi.Schema, + cfg?: ExpressJoiContainerConfig + ): express.RequestHandler; + query( + schema: Joi.Schema, + cfg?: ExpressJoiContainerConfig + ): express.RequestHandler; + params( + schema: Joi.Schema, + cfg?: ExpressJoiContainerConfig + ): express.RequestHandler; + headers( + schema: Joi.Schema, + cfg?: ExpressJoiContainerConfig + ): express.RequestHandler; + fields( + schema: Joi.Schema, + cfg?: ExpressJoiContainerConfig + ): express.RequestHandler; + response( + schema: Joi.Schema, + cfg?: ExpressJoiContainerConfig + ): express.RequestHandler; +} + +// These represent the incoming data containers that we might need to validate +const containers = { + query: { + storageProperty: 'originalQuery', + joi: { + convert: true, + allowUnknown: false, + abortEarly: false, + }, + }, + // For use with body-parser + body: { + storageProperty: 'originalBody', + joi: { + convert: true, + allowUnknown: false, + abortEarly: false, + }, + }, + headers: { + storageProperty: 'originalHeaders', + joi: { + convert: true, + allowUnknown: true, + stripUnknown: false, + abortEarly: false, + }, + }, + // URL params e.g "/users/:userId" + params: { + storageProperty: 'originalParams', + joi: { + convert: true, + allowUnknown: false, + abortEarly: false, + }, + }, + // For use with express-formidable or similar POST body parser for forms + fields: { + storageProperty: 'originalFields', + joi: { + convert: true, + allowUnknown: false, + abortEarly: false, + }, + }, +}; + +function buildErrorString(err, container) { + let ret = `Error validating ${container}.`; + let details = err.error.details; + + for (let i = 0; i < details.length; i++) { + ret += ` ${details[i].message}.`; + } + + return ret; +} + +export function createValidator(config?: ExpressJoiConfig): ExpressJoiInstance { + const cfg = config || {}; // default to an empty config + // We'll return this instance of the middleware + const instance = { + response, + }; + + Object.keys(containers).forEach((type) => { + // e.g the "body" or "query" from above + const container = containers[type]; + + instance[type] = function ( + schema: Joi.Schema, + options?: ExpressJoiContainerConfig + ) { + const opts = options || {}; // like config, default to empty object + const computedOpts = { ...container.joi, ...cfg.joi, ...opts.joi }; + return function expressJoiValidator( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) { + const ret = schema.validate(req[type], computedOpts) as any; + + if (!ret.error) { + req[container.storageProperty] = req[type]; + req[type] = ret.value; + next(); + } else if (opts.passError || cfg.passError) { + ret.type = type; + next(ret); + } else { + res.status(opts.statusCode || cfg.statusCode || 400).end( + buildErrorString(ret, `request ${type}`) + ); + } + }; + }; + }); + + return instance as any; + + function response(schema, options) { + const opts = options || {}; // like config, default to empty object + const type = 'response'; + return (req, res, next) => { + const resJson = res.json.bind(res); + res.json = validateJson; + next(); + + function validateJson(json) { + const ret = schema.validate(json, opts.joi); + const { error, value } = ret; + if (!error) { + // return res.json ret to retain express compatibility + return resJson(value); + } else if (opts.passError || cfg.passError) { + ret.type = type; + next(ret); + } else { + res.status(opts.statusCode || cfg.statusCode || 500).end( + buildErrorString(ret, `${type} json`) + ); + } + } + }; + } +} diff --git a/packages/api/src/validators/microcredit.ts b/packages/api/src/validators/microcredit.ts new file mode 100644 index 000000000..5642f6476 --- /dev/null +++ b/packages/api/src/validators/microcredit.ts @@ -0,0 +1,26 @@ +import { Joi } from 'celebrate'; + +import { defaultSchema } from './defaultSchema'; +import { + ContainerTypes, + createValidator, + ValidatedRequestSchema, +} from '../utils/queryValidator'; + +const validator = createValidator(); + +const queryListBorrowersSchema = defaultSchema.object({ + offset: Joi.number().optional(), + limit: Joi.number().optional(), +}); + +interface ListBorrowersRequestSchema extends ValidatedRequestSchema { + [ContainerTypes.Query]: { + offset?: number; + limit?: number; + }; +} + +const listBorrowersValidator = validator.query(queryListBorrowersSchema); + +export { listBorrowersValidator, ListBorrowersRequestSchema }; diff --git a/packages/core/src/database/db.ts b/packages/core/src/database/db.ts index 1dd887cb8..a3115dd04 100644 --- a/packages/core/src/database/db.ts +++ b/packages/core/src/database/db.ts @@ -1,4 +1,4 @@ -import { ModelCtor, Sequelize } from 'sequelize/types'; +import { ModelStatic, Sequelize } from 'sequelize/types'; import { AirgrabProofModel } from './models/airgrab/airgrabProof'; import { AirgrabUserModel } from './models/airgrab/airgrabUser'; @@ -51,63 +51,63 @@ import { WalletAirdropProofModel } from './models/walletAirdrop/walletAirdropPro import { WalletAirdropUserModel } from './models/walletAirdrop/walletAirdropUser'; export interface DbModels { - appUser: ModelCtor; - appProposal: ModelCtor; - appLog: ModelCtor; - appClientCredential: ModelCtor; - anonymousReport: ModelCtor; - cronJobExecuted: ModelCtor; - exchangeRates: ModelCtor; - imMetadata: ModelCtor; - appNotification: ModelCtor; - appUserValidationCode: ModelCtor; - ubiBeneficiarySurvey: ModelCtor; + appUser: ModelStatic; + appProposal: ModelStatic; + appLog: ModelStatic; + appClientCredential: ModelStatic; + anonymousReport: ModelStatic; + cronJobExecuted: ModelStatic; + exchangeRates: ModelStatic; + imMetadata: ModelStatic; + appNotification: ModelStatic; + appUserValidationCode: ModelStatic; + ubiBeneficiarySurvey: ModelStatic; // - community: ModelCtor; - ubiCommunitySuspect: ModelCtor; - ubiCommunityContract: ModelCtor; - ubiCommunityDailyMetrics: ModelCtor; - ubiCommunityDemographics: ModelCtor; - ubiPromoter: ModelCtor; - ubiPromoterSocialMedia: ModelCtor; - ubiCommunityLabels: ModelCtor; - ubiCommunityCampaign: ModelCtor; - ubiClaimLocation: ModelCtor; - beneficiary: ModelCtor; - manager: ModelCtor; + community: ModelStatic; + ubiCommunitySuspect: ModelStatic; + ubiCommunityContract: ModelStatic; + ubiCommunityDailyMetrics: ModelStatic; + ubiCommunityDemographics: ModelStatic; + ubiPromoter: ModelStatic; + ubiPromoterSocialMedia: ModelStatic; + ubiCommunityLabels: ModelStatic; + ubiCommunityCampaign: ModelStatic; + ubiClaimLocation: ModelStatic; + beneficiary: ModelStatic; + manager: ModelStatic; // - globalDailyState: ModelCtor; - globalDemographics: ModelCtor; - globalGrowth: ModelCtor; + globalDailyState: ModelStatic; + globalDemographics: ModelStatic; + globalGrowth: ModelStatic; // - storyContent: ModelCtor; - storyCommunity: ModelCtor; - storyEngagement: ModelCtor; - storyUserEngagement: ModelCtor; - storyUserReport: ModelCtor; - storyComment: ModelCtor; + storyContent: ModelStatic; + storyCommunity: ModelStatic; + storyEngagement: ModelStatic; + storyUserEngagement: ModelStatic; + storyUserReport: ModelStatic; + storyComment: ModelStatic; // - airgrabUser: ModelCtor; - airgrabProof: ModelCtor; + airgrabUser: ModelStatic; + airgrabProof: ModelStatic; // - walletAirdropUser: ModelCtor; - walletAirdropProof: ModelCtor; + walletAirdropUser: ModelStatic; + walletAirdropProof: ModelStatic; // - learnAndEarnCategory: ModelCtor; - learnAndEarnLesson: ModelCtor; - learnAndEarnLevel: ModelCtor; - learnAndEarnPrismicLevel: ModelCtor; - learnAndEarnPrismicLesson: ModelCtor; - learnAndEarnQuiz: ModelCtor; - learnAndEarnUserCategory: ModelCtor; - learnAndEarnUserLesson: ModelCtor; - learnAndEarnUserLevel: ModelCtor; - learnAndEarnPayment: ModelCtor; + learnAndEarnCategory: ModelStatic; + learnAndEarnLesson: ModelStatic; + learnAndEarnLevel: ModelStatic; + learnAndEarnPrismicLevel: ModelStatic; + learnAndEarnPrismicLesson: ModelStatic; + learnAndEarnQuiz: ModelStatic; + learnAndEarnUserCategory: ModelStatic; + learnAndEarnUserLesson: ModelStatic; + learnAndEarnUserLevel: ModelStatic; + learnAndEarnPayment: ModelStatic; // - merchantRegistry: ModelCtor; - merchantCommunity: ModelCtor; + merchantRegistry: ModelStatic; + merchantCommunity: ModelStatic; // - exchangeRegistry: ModelCtor; + exchangeRegistry: ModelStatic; } export interface DbLoader { sequelize: Sequelize; diff --git a/packages/core/src/services/app/user/index.ts b/packages/core/src/services/app/user/index.ts index 700b0fae5..1de1d6f67 100644 --- a/packages/core/src/services/app/user/index.ts +++ b/packages/core/src/services/app/user/index.ts @@ -46,6 +46,7 @@ export default class UserService { beneficiary: null, councilMember: null, manager: null, + loanManager: null, }; let userRules: { beneficiaryRules?: boolean; @@ -56,9 +57,9 @@ export default class UserService { if (userParams.phone) { const existsPhone = userParams.phone ? await this._existsAccountByPhone( - userParams.phone, - userParams.address - ) + userParams.phone, + userParams.address + ) : false; if (existsPhone) { diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 4ac81e529..3b0884005 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -3,12 +3,17 @@ import Email from './email'; import * as global from './global'; import { answer } from './learnAndEarn/answer'; import { registerClaimRewards } from './learnAndEarn/claimRewards'; -import { listLessons, listLevels, listLevelsByAdmin } from './learnAndEarn/list'; +import { + listLessons, + listLevels, + listLevelsByAdmin, +} from './learnAndEarn/list'; import { startLesson } from './learnAndEarn/start'; import { webhook } from './learnAndEarn/syncRemote'; import { total } from './learnAndEarn/userData'; import { createLevel } from './learnAndEarn/create'; -import MicrocreditService from './microcredit'; +import Protocol from './protocol'; +import * as MicroCredit from './microcredit'; import * as storage from './storage'; import StoryServiceV2 from './story/index'; import * as ubi from './ubi'; @@ -32,5 +37,6 @@ export { Email, StoryServiceV2, learnAndEarn, - MicrocreditService, + MicroCredit, + Protocol, }; diff --git a/packages/core/src/services/microcredit/index.ts b/packages/core/src/services/microcredit/index.ts index 2fb6d6722..3da6e5a3a 100644 --- a/packages/core/src/services/microcredit/index.ts +++ b/packages/core/src/services/microcredit/index.ts @@ -1,22 +1,3 @@ -import { queries } from '../../subgraph'; +import List from './list'; -export default class MicrocreditService { - public getGlobalData = async (): Promise => { - const subgraphData = await queries.microcredit.getGlobalData(); - - // TODO: calculate applications { totalApplications, inReview } - - const estimatedMaturity = 0; // (paid back in the past 3 months / 3 / current debt) - const avgBorrowedAmount = Math.round(subgraphData.totalBorrowed / subgraphData.activeBorrowers); - const apr = 0; // (paid back past 7 months - borrowed past 7 months) / borrowed past 7 months / 7 * 12 - - return { - totalApplications: 0, - inReview: 0, - estimatedMaturity, - avgBorrowedAmount, - apr, - ...subgraphData, - } - } -} \ No newline at end of file +export { List }; diff --git a/packages/core/src/services/microcredit/list.ts b/packages/core/src/services/microcredit/list.ts new file mode 100644 index 000000000..d2027e51d --- /dev/null +++ b/packages/core/src/services/microcredit/list.ts @@ -0,0 +1,54 @@ +import { models } from '../../database'; +import { getBorrowers } from '../../subgraph/queries/microcredit'; +import { getAddress } from '@ethersproject/address'; + +function mergeArrays(arr1: any[], arr2: any[], key: string) { + const map = new Map(arr1.map((item) => [item[key], item])); + arr2.forEach((item) => { + map.has(item[key]) + ? Object.assign(map.get(item[key]), item) + : map.set(item[key], item); + }); + return Array.from(map.values()); +} + +export default class MicroCreditList { + public listBorrowers = async (query: { + offset?: number; + limit?: number; + addedBy?: string; + }): Promise< + { + address: string; + firstName: string; + lastName: string; + avatarMediaPath: string; + loans: { + amount: string; + period: number; + dailyInterest: number; + claimed: number; + repayed: string; + lastRepayment: number; + }[]; + }[] + > => { + // get borrowers loans from subgraph + const borrowers = await getBorrowers(query); + + // get borrowers profile from database + const userProfile = await models.appUser.findAll({ + attributes: ['address', 'firstName', 'lastName', 'avatarMediaPath'], + where: { + address: borrowers.map((b) => getAddress(b.id)), + }, + }); + + // merge borrowers loans and profile + return mergeArrays( + borrowers.map((b) => ({ address: getAddress(b.id), ...b })), + userProfile.map((u) => u.toJSON()), + 'address' + ); + }; +} diff --git a/packages/core/src/services/protocol/index.ts b/packages/core/src/services/protocol/index.ts new file mode 100644 index 000000000..c299300eb --- /dev/null +++ b/packages/core/src/services/protocol/index.ts @@ -0,0 +1,24 @@ +import { queries } from '../../subgraph'; + +export default class ProtocolService { + public getMicroCreditData = async (): Promise => { + const subgraphData = await queries.microcredit.getGlobalData(); + + // TODO: calculate applications { totalApplications, inReview } + + const estimatedMaturity = 0; // (paid back in the past 3 months / 3 / current debt) + const avgBorrowedAmount = Math.round( + subgraphData.totalBorrowed / subgraphData.activeBorrowers + ); + const apr = 0; // (paid back past 7 months - borrowed past 7 months) / borrowed past 7 months / 7 * 12 + + return { + totalApplications: 0, + inReview: 0, + estimatedMaturity, + avgBorrowedAmount, + apr, + ...subgraphData, + }; + }; +} diff --git a/packages/core/src/subgraph/queries/microcredit.ts b/packages/core/src/subgraph/queries/microcredit.ts index 155ae7760..daebefda8 100644 --- a/packages/core/src/subgraph/queries/microcredit.ts +++ b/packages/core/src/subgraph/queries/microcredit.ts @@ -1,19 +1,21 @@ +import { intervalsInSeconds } from '../../types'; +import { redisClient } from '../../database'; import { axiosMicrocreditSubgraph } from '../config'; type Asset = { - id: string, - asset: string, - amount: string, -} + id: string; + asset: string; + amount: string; +}; export const getGlobalData = async (): Promise<{ - totalBorrowed: number, - currentDebt: number, - paidBack: number, - earnedInterest: number, - activeBorrowers: number, - totalDebitsRepaid: number, - liquidityAvailable: number, + totalBorrowed: number; + currentDebt: number; + paidBack: number; + earnedInterest: number; + activeBorrowers: number; + totalDebitsRepaid: number; + liquidityAvailable: number; }> => { try { const graphqlQuery = { @@ -59,13 +61,13 @@ export const getGlobalData = async (): Promise<{ data: { data: { microCredit: { - borrowed: Asset[], - debit: Asset[], - repaid: Asset[], - interest: Asset[], - borrowers: number, - repayments: number, - liquidity: Asset[], + borrowed: Asset[]; + debit: Asset[]; + repaid: Asset[]; + interest: Asset[]; + borrowers: number; + repayments: number; + liquidity: Asset[]; }; }; }; @@ -75,16 +77,116 @@ export const getGlobalData = async (): Promise<{ const microCredit = response.data?.data.microCredit; return { - totalBorrowed: microCredit.borrowed && microCredit.borrowed.length ? parseFloat(microCredit.borrowed[0].amount) : 0, - currentDebt: microCredit.debit && microCredit.debit.length ? parseFloat(microCredit.debit[0].amount) : 0, - paidBack: microCredit.repaid && microCredit.repaid.length ? parseFloat(microCredit.repaid[0].amount) : 0, - earnedInterest: microCredit.interest && microCredit.interest.length ? parseFloat(microCredit.interest[0].amount) : 0, + totalBorrowed: + microCredit.borrowed && microCredit.borrowed.length + ? parseFloat(microCredit.borrowed[0].amount) + : 0, + currentDebt: + microCredit.debit && microCredit.debit.length + ? parseFloat(microCredit.debit[0].amount) + : 0, + paidBack: + microCredit.repaid && microCredit.repaid.length + ? parseFloat(microCredit.repaid[0].amount) + : 0, + earnedInterest: + microCredit.interest && microCredit.interest.length + ? parseFloat(microCredit.interest[0].amount) + : 0, activeBorrowers: microCredit.borrowers ? microCredit.borrowers : 0, - totalDebitsRepaid: microCredit.repayments ? microCredit.repayments : 0, - liquidityAvailable: microCredit.liquidity && microCredit.liquidity.length ? parseFloat(microCredit.liquidity[0].amount) : 0, + totalDebitsRepaid: microCredit.repayments + ? microCredit.repayments + : 0, + liquidityAvailable: + microCredit.liquidity && microCredit.liquidity.length + ? parseFloat(microCredit.liquidity[0].amount) + : 0, }; - } catch (error) { throw new Error(error); } -}; \ No newline at end of file +}; + +export const getBorrowers = async (query: { + offset?: number; + limit?: number; + addedBy?: string; +}): Promise< + { + id: string; + loans: { + amount: string; + period: number; + dailyInterest: number; + claimed: number; + repayed: string; + lastRepayment: number; + }[]; + }[] +> => { + const graphqlQuery = { + operationName: 'borrowers', + query: `query borrowers { + borrowers( + first: ${query.limit ? query.limit : 10} + skip: ${query.offset ? query.offset : 0} + ) { + id + loans( + where: { + addedBy: "${ + query.addedBy ? query.addedBy.toLowerCase() : '' + }" + } + ) { + amount + period + dailyInterest + claimed + repayed + lastRepayment + } + } + }`, + }; + + const cacheResults = await redisClient.get(graphqlQuery.query); + + if (cacheResults) { + return JSON.parse(cacheResults); + } + + const response = await axiosMicrocreditSubgraph.post< + any, + { + data: { + data: { + borrowers: { + id: string; + loans: { + amount: string; + period: number; + dailyInterest: number; + claimed: number; + repayed: string; + lastRepayment: number; + }[]; + }[]; + }; + }; + } + >('', graphqlQuery); + + const borrowers = response.data?.data.borrowers.filter( + (b) => b.loans.length > 0 + ); + + redisClient.set( + graphqlQuery.query, + JSON.stringify(borrowers), + 'EX', + intervalsInSeconds.twoMins + ); + + return borrowers; +}; diff --git a/packages/core/src/subgraph/queries/user.ts b/packages/core/src/subgraph/queries/user.ts index bd4883ed1..ca35900fd 100644 --- a/packages/core/src/subgraph/queries/user.ts +++ b/packages/core/src/subgraph/queries/user.ts @@ -1,4 +1,8 @@ -import { axiosCouncilSubgraph, axiosSubgraph } from '../config'; +import { + axiosCouncilSubgraph, + axiosMicrocreditSubgraph, + axiosSubgraph, +} from '../config'; import { redisClient } from '../../database'; import { intervalsInSeconds } from '../../types'; @@ -7,6 +11,7 @@ export type UserRoles = { manager: { community: string; state: number } | null; councilMember: { state: number } | null; ambassador: { communities: string[]; state: number } | null; + loanManager: { state: number } | null; }; type DAOResponse = { @@ -33,6 +38,11 @@ type CouncilMemberResponse = { communities: string[]; }; }; +type MicroCreditResponse = { + loanManager: { + state: number; + }; +}; export const getUserRoles = async (address: string): Promise => { try { @@ -74,38 +84,62 @@ export const getUserRoles = async (address: string): Promise => { } }`, }; + const microcreditGraphqlQuery = { + operationName: 'loanManager', + query: `query loanManager { + loanManager( + id: "${address.toLowerCase()}" + ) { + state + } + }`, + }; - const [cacheDAO, cacheCouncil] = await Promise.all([ + const [cacheDAO, cacheCouncil, microcredit] = await Promise.all([ redisClient.get(graphqlQuery.query), redisClient.get(councilGraphqlQuery.query), + redisClient.get(microcreditGraphqlQuery.query), ]); let responseDAO: DAOResponse | null = null; let responseCouncilMember: CouncilMemberResponse | null = null; + let responseMicroCredit: MicroCreditResponse | null = null; - if (cacheDAO && cacheCouncil) { + if (cacheDAO && cacheCouncil && microcredit) { responseDAO = JSON.parse(cacheDAO); responseCouncilMember = JSON.parse(cacheCouncil); + responseMicroCredit = JSON.parse(microcredit); } else { - const [rawResponseDAO, rawResponseCouncilMember] = - await Promise.all([ - axiosSubgraph.post< - any, - { - data: { - data: DAOResponse; - }; - } - >('', graphqlQuery), - axiosCouncilSubgraph.post< - any, - { - data: { - data: CouncilMemberResponse; - }; - } - >('', councilGraphqlQuery), - ]); + const [ + rawResponseDAO, + rawResponseCouncilMember, + rawResponseMicroCredit, + ] = await Promise.all([ + axiosSubgraph.post< + any, + { + data: { + data: DAOResponse; + }; + } + >('', graphqlQuery), + axiosCouncilSubgraph.post< + any, + { + data: { + data: CouncilMemberResponse; + }; + } + >('', councilGraphqlQuery), + axiosMicrocreditSubgraph.post< + any, + { + data: { + data: MicroCreditResponse; + }; + } + >('', microcreditGraphqlQuery), + ]); responseDAO = rawResponseDAO.data?.data; @@ -124,43 +158,59 @@ export const getUserRoles = async (address: string): Promise => { 'EX', intervalsInSeconds.oneHour ); + + responseMicroCredit = rawResponseMicroCredit.data?.data; + + redisClient.set( + microcreditGraphqlQuery.query, + JSON.stringify(responseMicroCredit), + 'EX', + intervalsInSeconds.oneHour + ); } const beneficiary = !responseDAO?.beneficiaryEntity ? null : { - community: responseDAO?.beneficiaryEntity?.community?.id, - state: responseDAO?.beneficiaryEntity?.state, - address: responseDAO?.beneficiaryEntity?.address, - }; + community: responseDAO?.beneficiaryEntity?.community?.id, + state: responseDAO?.beneficiaryEntity?.state, + address: responseDAO?.beneficiaryEntity?.address, + }; const manager = !responseDAO?.managerEntity ? null : { - community: responseDAO.managerEntity?.community?.id, - state: responseDAO.managerEntity?.state, - }; + community: responseDAO.managerEntity?.community?.id, + state: responseDAO.managerEntity?.state, + }; const councilMember = !responseCouncilMember?.impactMarketCouncilMemberEntity ? null : { - state: responseCouncilMember - .impactMarketCouncilMemberEntity.status, - }; + state: responseCouncilMember + .impactMarketCouncilMemberEntity.status, + }; const ambassador = !responseCouncilMember?.ambassadorEntity ? null : { - communities: - responseCouncilMember.ambassadorEntity?.communities, - state: responseCouncilMember.ambassadorEntity?.status, - }; + communities: + responseCouncilMember.ambassadorEntity?.communities, + state: responseCouncilMember.ambassadorEntity?.status, + }; + + const loanManager = !responseMicroCredit?.loanManager + ? null + : { + state: responseMicroCredit.loanManager?.state, + }; return { beneficiary, manager, councilMember, ambassador, + loanManager, }; } catch (error) { throw new Error(error); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0a2d62756..ffda46851 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,12 +1,13 @@ // API to app export const enum intervalsInSeconds { + twoMins = 120, halfHour = 1800, oneHour = 3600, sixHours = 21600, twelveHours = 43200, oneDay = 86400, -}; +} /** * @deprecated