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/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/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/services/index.ts b/packages/core/src/services/index.ts index 794151ed9..ff02ae325 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -7,7 +7,8 @@ import { listLessons, listLevels } from './learnAndEarn/list'; import { startLesson } from './learnAndEarn/start'; import { webhook } from './learnAndEarn/syncRemote'; import { total } from './learnAndEarn/userData'; -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'; @@ -29,5 +30,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/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/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