diff --git a/packages/api/src/controllers/v2/savingCircle.ts b/packages/api/src/controllers/v2/savingCircle.ts new file mode 100644 index 000000000..6b1690656 --- /dev/null +++ b/packages/api/src/controllers/v2/savingCircle.ts @@ -0,0 +1,28 @@ +import { Response } from 'express'; +import { services } from '@impactmarket/core'; + +import { RequestWithUser } from '../../middlewares/core'; +import { standardResponse } from '../../utils/api'; + +class SavingCircleController { + savingCircleService = new services.SavingCircleService(); + + create = async (req: RequestWithUser, res: Response) => { + if (req.user === undefined) { + standardResponse(res, 401, false, '', { + error: { + name: 'USER_NOT_FOUND', + message: 'User not identified!' + } + }); + return; + } + const { name, country, amount, frequency, firstDepositOn, members } = req.body; + this.savingCircleService + .create(req.user, { name, country, amount, frequency, firstDepositOn, members }) + .then(r => standardResponse(res, 200, true, r)) + .catch(e => standardResponse(res, 400, false, '', { error: e })); + }; +} + +export default SavingCircleController; diff --git a/packages/api/src/routes/v2/index.ts b/packages/api/src/routes/v2/index.ts index 95920a898..33f080433 100644 --- a/packages/api/src/routes/v2/index.ts +++ b/packages/api/src/routes/v2/index.ts @@ -11,6 +11,7 @@ import learnAndEarn from './learnAndEarn'; import microcredit from './microcredit'; import protocol from './protocol'; import referrals from './referrals'; +import savingCircle from './savingCircle'; import story from './story'; import user from './user'; @@ -29,6 +30,7 @@ export default (): Router => { referrals(app); cico(app); lazyAgenda(app); + savingCircle(app); return app; }; diff --git a/packages/api/src/routes/v2/savingCircle.ts b/packages/api/src/routes/v2/savingCircle.ts new file mode 100644 index 000000000..4d2c7c3ca --- /dev/null +++ b/packages/api/src/routes/v2/savingCircle.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; + +import { authenticateToken } from '../../middlewares'; +import SavingCircleController from '../../controllers/v2/savingCircle'; +import SavingCircleValidator from '../../validators/savingCircle'; + +export default (app: Router): void => { + const savingCircleController = new SavingCircleController(); + const savingCircleValidator = new SavingCircleValidator(); + const route = Router(); + app.use('/saving-circles', route); + + /** + * @swagger + * + * /saving-circles: + * post: + * tags: + * - "saving-circles" + * summary: Create saving circle + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: Group name + * members: + * description: Member addresses + * type: array + * items: + * type: string + * country: + * type: string + * description: Group country (2 digits) + * amount: + * type: number + * description: Saving amount + * frequency: + * type: number + * description: Saving frequency (seconds) + * firstDepositOn: + * type: number + * description: First deposit on (timestamp) + * responses: + * "200": + * description: "Success" + * security: + * - BearerToken: [] + */ + route.post('/', authenticateToken, savingCircleValidator.create, savingCircleController.create); +}; diff --git a/packages/api/src/validators/savingCircle.ts b/packages/api/src/validators/savingCircle.ts new file mode 100644 index 000000000..ca577995d --- /dev/null +++ b/packages/api/src/validators/savingCircle.ts @@ -0,0 +1,18 @@ +import { Joi, celebrate } from 'celebrate'; + +import { defaultSchema } from './defaultSchema'; + +class SavingCirclelidator { + create = celebrate({ + body: defaultSchema.object({ + name: Joi.string().required(), + country: Joi.string().length(2).required(), + amount: Joi.number().required(), + frequency: Joi.number().required(), + firstDepositOn: Joi.number().required(), + members: Joi.array().items(Joi.string()).min(2).required() + }) + }); +} + +export default SavingCirclelidator; diff --git a/packages/core/src/database/db.ts b/packages/core/src/database/db.ts index e0f05403c..1f91a2eb8 100644 --- a/packages/core/src/database/db.ts +++ b/packages/core/src/database/db.ts @@ -40,6 +40,8 @@ import { MicroCreditBorrowersModel } from './models/microCredit/borrowers'; import { MicroCreditDocsModel } from './models/microCredit/docs'; import { MicroCreditLoanManagerModel } from './models/microCredit/loanManagers'; import { MicroCreditNoteModel } from './models/microCredit/note'; +import { SavingCircleMemberModel } from './models/savingCircle/savingCircleMember'; +import { SavingCircleModel } from './models/savingCircle/savingCircle'; import { StoryCommentModel } from './models/story/storyComment'; import { StoryCommunityModel } from './models/story/storyCommunity'; import { StoryContentModel } from './models/story/storyContent'; @@ -133,6 +135,9 @@ export type DbModels = { microCreditLoanManager: ModelStatic; // exchangeRegistry: ModelStatic; + // + savingCircle: ModelStatic; + savingCircleMember: ModelStatic; }; export interface DbLoader { sequelize: Sequelize; diff --git a/packages/core/src/database/migrations/z20231030203320-create-saving-circle.js b/packages/core/src/database/migrations/z20231030203320-create-saving-circle.js new file mode 100644 index 000000000..1a0a96628 --- /dev/null +++ b/packages/core/src/database/migrations/z20231030203320-create-saving-circle.js @@ -0,0 +1,58 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('saving_circle', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + name: { + type: Sequelize.STRING(64), + allowNull: false, + }, + country: { + type: Sequelize.STRING(2), + allowNull: false + }, + amount: { + type: Sequelize.FLOAT, + allowNull: false, + }, + frequency: { + type: Sequelize.INTEGER, + allowNull: false, + }, + firstDepositOn: { + type: Sequelize.DATEONLY, + allowNull: false, + }, + requestedBy: { + type: Sequelize.INTEGER, + unique: true, + references: { + model: 'app_user', + key: 'id', + }, + allowNull: false, + }, + status: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('saving_circle'); + }, +}; diff --git a/packages/core/src/database/migrations/z20231030212330-create-saving-circle-member.js b/packages/core/src/database/migrations/z20231030212330-create-saving-circle-member.js new file mode 100644 index 000000000..36b2f4825 --- /dev/null +++ b/packages/core/src/database/migrations/z20231030212330-create-saving-circle-member.js @@ -0,0 +1,51 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('saving_circle_member', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: Sequelize.INTEGER, + references: { + model: 'app_user', + key: 'id', + }, + onDelete: 'CASCADE', + allowNull: false, + }, + groupId: { + type: Sequelize.INTEGER, + references: { + model: 'saving_circle', + key: 'id', + }, + onDelete: 'CASCADE', + allowNull: false, + }, + accept: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + decisionOn: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('saving_circle_member'); + }, +}; diff --git a/packages/core/src/database/models/index.ts b/packages/core/src/database/models/index.ts index 248e1232f..147a864a5 100644 --- a/packages/core/src/database/models/index.ts +++ b/packages/core/src/database/models/index.ts @@ -40,6 +40,8 @@ import { initializeMicroCreditBorrowersHuma } from './microCredit/borrowersHuma' import { initializeMicroCreditDocs } from './microCredit/docs'; import { initializeMicroCreditLoanManager } from './microCredit/loanManagers'; import { initializeMicroCreditNote } from './microCredit/note'; +import { initializeSavingCircle } from './savingCircle/savingCircle'; +import { initializeSavingCircleMember } from './savingCircle/savingCircleMember'; import { initializeStoryComment } from './story/storyComment'; import { initializeStoryCommunity } from './story/storyCommunity'; import { initializeStoryContent } from './story/storyContent'; @@ -149,6 +151,10 @@ export default function initModels(sequelize: Sequelize): void { // Exchange initializeExchangeRegistry(sequelize); + // Saving Circle + initializeSavingCircle(sequelize); + initializeSavingCircleMember(sequelize); + // associations userAssociation(sequelize); communityAssociation(sequelize); diff --git a/packages/core/src/database/models/savingCircle/savingCircle.ts b/packages/core/src/database/models/savingCircle/savingCircle.ts new file mode 100644 index 000000000..e271ca4a7 --- /dev/null +++ b/packages/core/src/database/models/savingCircle/savingCircle.ts @@ -0,0 +1,80 @@ +import { DataTypes, Model, Sequelize } from 'sequelize'; +import { DbModels } from '../../../database/db'; + +import { SavingCircle, SavingCircleCreation } from '../../../interfaces/savingCircle/savingCircle'; + +export class SavingCircleModel extends Model { + public id!: number; + public name!: string; + public country!: string; + public amount!: number; + public frequency!: number; + public firstDepositOn!: Date; + public requestedBy!: number; + public status!: number; + + // timestamps! + public readonly createdAt!: Date; + public readonly updatedAt!: Date; +} + +export function initializeSavingCircle(sequelize: Sequelize): typeof SavingCircleModel { + const { appUser } = sequelize.models as DbModels; + SavingCircleModel.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + name: { + type: DataTypes.STRING(64), + allowNull: false + }, + country: { + type: DataTypes.STRING(2), + allowNull: false + }, + amount: { + type: DataTypes.FLOAT, + allowNull: false + }, + frequency: { + type: DataTypes.INTEGER, + allowNull: false + }, + firstDepositOn: { + type: DataTypes.DATEONLY, + allowNull: false + }, + requestedBy: { + type: DataTypes.INTEGER, + unique: true, + references: { + model: appUser, + key: 'id' + }, + allowNull: false + }, + status: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { + tableName: 'saving_circle', + modelName: 'savingCircle', + sequelize + } + ); + return SavingCircleModel; +} diff --git a/packages/core/src/database/models/savingCircle/savingCircleMember.ts b/packages/core/src/database/models/savingCircle/savingCircleMember.ts new file mode 100644 index 000000000..9eb322586 --- /dev/null +++ b/packages/core/src/database/models/savingCircle/savingCircleMember.ts @@ -0,0 +1,75 @@ +import { DataTypes, Model, Sequelize } from 'sequelize'; + +import { AppUserModel } from '../app/appUser'; +import { DbModels } from '../../../database/db'; +import { SavingCircleMember, SavingCircleMemberCreation } from '../../../interfaces/savingCircle/savingCircleMember'; +import { SavingCircleModel } from './savingCircle'; + +export class SavingCircleMemberModel extends Model { + public id!: number; + public userId!: number; + public groupId!: number; + public accept!: boolean; + public decisionOn!: Date; + + // timestamps! + public readonly createdAt!: Date; + public readonly updatedAt!: Date; + + public readonly user?: AppUserModel; + public readonly group?: SavingCircleModel; +} + +export function initializeSavingCircleMember(sequelize: Sequelize): typeof SavingCircleMemberModel { + const { appUser, savingCircle } = sequelize.models as DbModels; + SavingCircleMemberModel.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: DataTypes.INTEGER, + references: { + model: appUser, + key: 'id' + }, + onDelete: 'CASCADE', + allowNull: false + }, + groupId: { + type: DataTypes.INTEGER, + references: { + model: savingCircle, + key: 'id' + }, + onDelete: 'CASCADE', + allowNull: false + }, + accept: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + decisionOn: { + type: DataTypes.DATE, + allowNull: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { + tableName: 'saving_circle_member', + modelName: 'savingCircleMember', + sequelize + } + ); + return SavingCircleMemberModel; +} diff --git a/packages/core/src/interfaces/app/appNotification.ts b/packages/core/src/interfaces/app/appNotification.ts index e5b267571..166ccebd5 100644 --- a/packages/core/src/interfaces/app/appNotification.ts +++ b/packages/core/src/interfaces/app/appNotification.ts @@ -53,7 +53,8 @@ export enum NotificationType { LOW_PERFORMANCE = 17, HIGH_PERFORMANCE = 18, LEARN_AND_EARN_FINISH_LEVEL = 19, - LEARN_AND_EARN_NEW_LEVEL = 20 + LEARN_AND_EARN_NEW_LEVEL = 20, + SAVING_GROUP_INVITE = 21 } export type NotificationParams = { diff --git a/packages/core/src/interfaces/savingCircle/savingCircle.ts b/packages/core/src/interfaces/savingCircle/savingCircle.ts new file mode 100644 index 000000000..b30f2c551 --- /dev/null +++ b/packages/core/src/interfaces/savingCircle/savingCircle.ts @@ -0,0 +1,24 @@ +export interface SavingCircle { + id: number; + name: string; + country: string; + amount: number; + frequency: number; + firstDepositOn: Date; + requestedBy: number; + status: number; + + // timestamps + createdAt: Date; + updatedAt: Date; +} + +export interface SavingCircleCreation { + name: string; + country: string; + amount: number; + frequency: number; + firstDepositOn: Date; + requestedBy: number; + status: number; +} diff --git a/packages/core/src/interfaces/savingCircle/savingCircleMember.ts b/packages/core/src/interfaces/savingCircle/savingCircleMember.ts new file mode 100644 index 000000000..3a07f25d6 --- /dev/null +++ b/packages/core/src/interfaces/savingCircle/savingCircleMember.ts @@ -0,0 +1,18 @@ +export interface SavingCircleMember { + id: number; + userId: number; + groupId: number; + accept: boolean; + decisionOn: Date; + + // timestamps + createdAt: Date; + updatedAt: Date; +} + +export interface SavingCircleMemberCreation { + userId: number; + groupId: number; + accept?: boolean; + decisionOn?: Date; +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 2ccdd88ff..eadc1061a 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -11,6 +11,7 @@ import { registerClaimRewards } from './learnAndEarn/claimRewards'; import { startLesson } from './learnAndEarn/start'; import { webhook } from './learnAndEarn/syncRemote'; import Protocol from './protocol'; +import SavingCircleService from './savingCircle/index'; import StoryServiceV2 from './story/index'; const learnAndEarn = { @@ -25,4 +26,4 @@ const learnAndEarn = { createLevel, recalculate }; -export { app, global, storage, ubi, StoryServiceV2, learnAndEarn, MicroCredit, Protocol }; +export { app, global, storage, ubi, StoryServiceV2, learnAndEarn, MicroCredit, Protocol, SavingCircleService }; diff --git a/packages/core/src/services/savingCircle/index.ts b/packages/core/src/services/savingCircle/index.ts new file mode 100644 index 000000000..a42bd06ac --- /dev/null +++ b/packages/core/src/services/savingCircle/index.ts @@ -0,0 +1,92 @@ +import { BaseError } from '../../utils'; +import { NotificationType } from '../../interfaces/app/appNotification'; +import { Op } from 'sequelize'; +import { SavingCircleMemberModel } from '../../database/models/savingCircle/savingCircleMember'; +import { getAddress } from '@ethersproject/address'; +import { models, sequelize } from '../../database'; +import { sendNotification } from '../../utils/pushNotification'; + +export default class SavingCircleService { + public async create( + user: { + address: string; + userId: number; + }, + group: { + name: string; + country: string; + amount: number; + frequency: number; + firstDepositOn: number; + members: string[]; + } + ): Promise<{ + name: string; + country: string; + amount: number; + frequency: number; + firstDepositOn: Date; + status: number; + members: SavingCircleMemberModel[]; + }> { + const t = await sequelize.transaction(); + + try { + const { name, country, amount, frequency, firstDepositOn, members } = group; + + // check if all members already have an account + const addresses = members.map(getAddress); + const users = await models.appUser.findAll({ + attributes: ['id', 'address', 'walletPNT'], + where: { address: { [Op.in]: [...addresses, user.address] } }, + raw: true + }); + + const invalidAddresses: string[] = addresses.filter( + address => !users.some(user => user.address === address) + ); + + if (invalidAddresses.length) { + throw new BaseError('InvalidAddresses', invalidAddresses.toString()); + } + + const userIds = users.map(user => user.id); + const groupCreated = await models.savingCircle.create( + { + name, + country, + amount, + frequency, + firstDepositOn: new Date(firstDepositOn), + requestedBy: user.userId, + status: 0 + }, + { transaction: t } + ); + + const memberAdded = await models.savingCircleMember.bulkCreate( + userIds.map(id => ({ + userId: id, + groupId: groupCreated.id + })), + { transaction: t } + ); + + await sendNotification(users, NotificationType.SAVING_GROUP_INVITE, false, true, undefined, t); + + await t.commit(); + + return { + ...groupCreated.toJSON(), + members: memberAdded + }; + } catch (error) { + await t.rollback(); + console.log(error); + throw new BaseError( + error.name || 'SavingCircleCreationError', + error.message || 'An error occurred while creating the saving circle' + ); + } + } +}