diff --git a/migration/1727612450457-addProjectUserRecord.ts b/migration/1727612450457-addProjectUserRecord.ts new file mode 100644 index 000000000..beab0e076 --- /dev/null +++ b/migration/1727612450457-addProjectUserRecord.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProjectUserRecord1727612450457 implements MigrationInterface { + name = 'AddProjectUserRecord1727612450457'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "project_user_record" ("id" SERIAL NOT NULL, "totalDonationAmount" double precision NOT NULL DEFAULT '0', "eaTotalDonationAmount" double precision NOT NULL DEFAULT '0', "qfTotalDonationAmount" double precision NOT NULL DEFAULT '0', "projectId" integer NOT NULL, "userId" integer NOT NULL, CONSTRAINT "PK_491352d8cb0de1670d85f622f30" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_29abdbcc3e6e7090cbc8fb1a90" ON "project_user_record" ("projectId", "userId") `, + ); + await queryRunner.query( + `ALTER TABLE "project_user_record" ADD CONSTRAINT "FK_6481d6181bd857725e903b0f330" FOREIGN KEY ("projectId") REFERENCES "project"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "project_user_record" ADD CONSTRAINT "FK_47c452701e3e8553fb01a904256" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project_user_record" DROP CONSTRAINT "FK_47c452701e3e8553fb01a904256"`, + ); + await queryRunner.query( + `ALTER TABLE "project_user_record" DROP CONSTRAINT "FK_6481d6181bd857725e903b0f330"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_29abdbcc3e6e7090cbc8fb1a90"`, + ); + await queryRunner.query(`DROP TABLE "project_user_record"`); + } +} diff --git a/package.json b/package.json index 19025d718..576312e61 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,8 @@ "test:projectUpdateRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/projectUpdateRepository.test.ts", "test:broadcastNotificationRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/broadcastNotificationRepository.test.ts", "test:projectAddressRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/projectAddressRepository.test.ts", - "test:ProjectRoundRecordRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/ProjectRoundRecordRepository.test.ts", + "test:ProjectRoundRecordRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/projectRoundRecordRepository.test.ts", + "test:ProjectUserRecordRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/projectUserRecordRepository.test.ts", "test:userPassportScoreRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/userPassportScoreRepository.test.ts", "test:donationService": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/qfRoundHistoryRepository.test.ts ./src/services/donationService.test.ts", "test:draftDonationService": "NODE_ENV=test mocha -t 99999 ./test/pre-test-scripts.ts src/services/chains/evm/draftDonationService.test.ts src/repositories/draftDonationRepository.test.ts src/workers/draftDonationMatchWorker.test.ts src/resolvers/draftDonationResolver.test.ts", diff --git a/src/entities/entities.ts b/src/entities/entities.ts index 6ffbd3ff7..1baacf651 100644 --- a/src/entities/entities.ts +++ b/src/entities/entities.ts @@ -34,6 +34,7 @@ import { UserQfRoundModelScore } from './userQfRoundModelScore'; import { UserEmailVerification } from './userEmailVerification'; import { EarlyAccessRound } from './earlyAccessRound'; import { ProjectRoundRecord } from './projectRoundRecord'; +import { ProjectUserRecord } from './projectUserRecord'; export const getEntities = (): DataSourceOptions['entities'] => { return [ @@ -80,5 +81,6 @@ export const getEntities = (): DataSourceOptions['entities'] => { UserQfRoundModelScore, EarlyAccessRound, ProjectRoundRecord, + ProjectUserRecord, ]; }; diff --git a/src/entities/projectUserRecord.ts b/src/entities/projectUserRecord.ts new file mode 100644 index 000000000..bcdfa5a93 --- /dev/null +++ b/src/entities/projectUserRecord.ts @@ -0,0 +1,54 @@ +import { Field, Float, ID, ObjectType } from 'type-graphql'; +import { + BaseEntity, + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + RelationId, +} from 'typeorm'; +import { Project } from './project'; +import { ProjectRoundRecord } from './projectRoundRecord'; +import { User } from './user'; + +@Entity() +@ObjectType() +@Index(['projectId', 'userId'], { + unique: true, +}) +export class ProjectUserRecord extends BaseEntity { + @Field(_type => ID) + @PrimaryGeneratedColumn() + id: number; + + @Field(_type => Float) + @Column({ type: 'float', default: 0 }) + totalDonationAmount: number; + + // Early access total donation amount + @Field(_type => Float) + @Column({ type: 'float', default: 0 }) + eaTotalDonationAmount: number; + + // QF rounds total donation amount + @Field(_type => Float) + @Column({ type: 'float', default: 0 }) + qfTotalDonationAmount: number; + + @Field(_type => Project) + @ManyToOne(_type => Project, { eager: true }) + project: Project; + + @Column({ nullable: false }) + @RelationId((ps: ProjectRoundRecord) => ps.project) + projectId: number; + + @Field(_type => User) + @ManyToOne(_type => User, { eager: true }) + user: User; + + @Column({ nullable: false }) + @RelationId((ps: ProjectUserRecord) => ps.user) + userId: number; +} diff --git a/src/repositories/earlyAccessRoundRepository.test.ts b/src/repositories/earlyAccessRoundRepository.test.ts index 8d61e34ac..01827a4de 100644 --- a/src/repositories/earlyAccessRoundRepository.test.ts +++ b/src/repositories/earlyAccessRoundRepository.test.ts @@ -7,7 +7,10 @@ import { findActiveEarlyAccessRound, fillMissingTokenPriceInEarlyAccessRounds, } from './earlyAccessRoundRepository'; -import { saveRoundDirectlyToDb } from '../../test/testUtils'; +import { + generateEARoundNumber, + saveEARoundDirectlyToDb, +} from '../../test/testUtils'; import { CoingeckoPriceAdapter } from '../adapters/price/CoingeckoPriceAdapter'; import { QACC_DONATION_TOKEN_COINGECKO_ID } from '../constants/qacc'; @@ -37,12 +40,12 @@ describe('EarlyAccessRound Repository Test Cases', () => { it('should save a new Early Access Round directly to the database', async () => { const roundData = { - roundNumber: 1, + roundNumber: generateEARoundNumber(), startDate: new Date('2024-09-01'), endDate: new Date('2024-09-05'), }; - const savedRound = await saveRoundDirectlyToDb(roundData); + const savedRound = await saveEARoundDirectlyToDb(roundData); expect(savedRound).to.be.an.instanceof(EarlyAccessRound); expect(savedRound.roundNumber).to.equal(roundData.roundNumber); @@ -56,13 +59,13 @@ describe('EarlyAccessRound Repository Test Cases', () => { it('should find all Early Access Rounds', async () => { // Save a couple of rounds first - await saveRoundDirectlyToDb({ - roundNumber: 1, + await saveEARoundDirectlyToDb({ + roundNumber: generateEARoundNumber(), startDate: new Date('2024-09-01'), endDate: new Date('2024-09-05'), }); - await saveRoundDirectlyToDb({ - roundNumber: 2, + await saveEARoundDirectlyToDb({ + roundNumber: generateEARoundNumber(), startDate: new Date('2024-09-06'), endDate: new Date('2024-09-10'), }); @@ -77,20 +80,20 @@ describe('EarlyAccessRound Repository Test Cases', () => { it('should find the active Early Access Round', async () => { const activeRoundData = { - roundNumber: 1, + roundNumber: generateEARoundNumber(), startDate: new Date(new Date().setDate(new Date().getDate() - 1)), // yesterday endDate: new Date(new Date().setDate(new Date().getDate() + 1)), // tomorrow }; const inactiveRoundData = { - roundNumber: 2, + roundNumber: generateEARoundNumber(), startDate: new Date(new Date().getDate() + 1), endDate: new Date(new Date().getDate() + 2), }; // Save both active and inactive rounds - await saveRoundDirectlyToDb(activeRoundData); - await saveRoundDirectlyToDb(inactiveRoundData); + await saveEARoundDirectlyToDb(activeRoundData); + await saveEARoundDirectlyToDb(inactiveRoundData); const activeRound = await findActiveEarlyAccessRound(); diff --git a/src/repositories/projectRoundRecordRepository.test.ts b/src/repositories/projectRoundRecordRepository.test.ts index e7dccabde..3148b45d7 100644 --- a/src/repositories/projectRoundRecordRepository.test.ts +++ b/src/repositories/projectRoundRecordRepository.test.ts @@ -2,6 +2,8 @@ import { expect } from 'chai'; import { createDonationData, createProjectData, + generateEARoundNumber, + generateQfRoundNumber, saveDonationDirectlyToDb, saveProjectDirectlyToDb, SEED_DATA, @@ -21,25 +23,19 @@ describe('ProjectRoundRecord test cases', () => { let earlyAccessRound1, earlyAccessRound2, earlyAccessRound3; let qfRound1, qfRound2; - async function insertDonation({ - amount, - valueUsd, - earlyAccessRoundId, - qfRoundId, - }: { - amount: number; - valueUsd: number; - earlyAccessRoundId?: number; - qfRoundId?: number; - }) { + async function insertDonation( + overrides: Partial< + Pick< + Donation, + 'amount' | 'valueUsd' | 'earlyAccessRoundId' | 'qfRoundId' | 'status' + > + >, + ) { return saveDonationDirectlyToDb( { ...createDonationData(), - amount, - valueUsd, - earlyAccessRoundId, - qfRoundId, status: DONATION_STATUS.VERIFIED, + ...overrides, }, SEED_DATA.FIRST_USER.id, projectId, @@ -58,17 +54,17 @@ describe('ProjectRoundRecord test cases', () => { const earlyAccessRounds = await EarlyAccessRound.create([ { - roundNumber: 1, + roundNumber: generateEARoundNumber(), startDate: new Date('2000-01-01'), endDate: new Date('2000-01-02'), }, { - roundNumber: 2, + roundNumber: generateEARoundNumber(), startDate: new Date('2000-01-02'), endDate: new Date('2000-01-03'), }, { - roundNumber: 3, + roundNumber: generateEARoundNumber(), startDate: new Date('2000-01-03'), endDate: new Date('2000-01-04'), }, @@ -78,7 +74,7 @@ describe('ProjectRoundRecord test cases', () => { earlyAccessRounds; qfRound1 = await QfRound.create({ - roundNumber: 1, + roundNumber: generateQfRoundNumber(), isActive: true, name: new Date().toString() + ' - 1', allocatedFund: 100, @@ -88,7 +84,7 @@ describe('ProjectRoundRecord test cases', () => { endDate: new Date('2001-01-03'), }).save(); qfRound2 = await QfRound.create({ - roundNumber: 2, + roundNumber: generateQfRoundNumber(), isActive: true, name: new Date().toString() + ' - 2', allocatedFund: 100, @@ -112,7 +108,15 @@ describe('ProjectRoundRecord test cases', () => { const amount = 100; const valueUsd = 150; + const unverifiedAmount = 200; + const unverifiedValueUsd = 300; + await insertDonation({ amount, valueUsd }); + await insertDonation({ + amount: unverifiedAmount, + valueUsd: unverifiedValueUsd, + status: DONATION_STATUS.PENDING, + }); await updateOrCreateProjectRoundRecord(projectId); diff --git a/src/repositories/projectUserRecordRepository.test.ts b/src/repositories/projectUserRecordRepository.test.ts new file mode 100644 index 000000000..2ddad08ab --- /dev/null +++ b/src/repositories/projectUserRecordRepository.test.ts @@ -0,0 +1,207 @@ +import { assert } from 'chai'; +import moment from 'moment'; +import { + createDonationData, + createProjectData, + generateEARoundNumber, + generateQfRoundNumber, + generateRandomEtheriumAddress, + saveDonationDirectlyToDb, + saveEARoundDirectlyToDb, + saveProjectDirectlyToDb, + saveUserDirectlyToDb, +} from '../../test/testUtils'; +import { + getProjectUserRecordAmount, + updateOrCreateProjectUserRecord, +} from './projectUserRecordRepository'; +import { DONATION_STATUS } from '../entities/donation'; +import { QfRound } from '../entities/qfRound'; + +describe('projectUserRecordRepository', () => { + let project; + let user; + + beforeEach(async () => { + project = await saveProjectDirectlyToDb(createProjectData()); + user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + }); + + it('should return 0 when there is no donation', async () => { + const projectUserRecord = await updateOrCreateProjectUserRecord({ + projectId: project.id, + userId: user.id, + }); + + assert.isOk(projectUserRecord); + assert.equal(projectUserRecord.totalDonationAmount, 0); + }); + + it('should return the total verified donation amount', async () => { + const verifiedDonationAmount1 = 100; + const verifiedDonationAmount2 = 200; + const unverifiedDonationAmount = 300; + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: verifiedDonationAmount1, + status: DONATION_STATUS.VERIFIED, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: verifiedDonationAmount2, + status: DONATION_STATUS.VERIFIED, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: unverifiedDonationAmount, + status: DONATION_STATUS.PENDING, + }, + user.id, + project.id, + ); + + const projectUserRecord = await updateOrCreateProjectUserRecord({ + projectId: project.id, + userId: user.id, + }); + + assert.isOk(projectUserRecord); + assert.equal( + projectUserRecord.totalDonationAmount, + verifiedDonationAmount1 + verifiedDonationAmount2, + ); + }); + + it('should return the total verified donation amount for a specific project', async () => { + const verifiedDonationAmount1 = 100; + const verifiedDonationAmount2 = 200; + const unverifiedDonationAmount = 300; + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: verifiedDonationAmount1, + status: DONATION_STATUS.VERIFIED, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: verifiedDonationAmount2, + status: DONATION_STATUS.VERIFIED, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: unverifiedDonationAmount, + status: DONATION_STATUS.PENDING, + }, + user.id, + project.id, + ); + + await updateOrCreateProjectUserRecord({ + projectId: project.id, + userId: user.id, + }); + + const amount = await getProjectUserRecordAmount({ + projectId: project.id, + userId: user.id, + }); + + assert.equal( + amount.totalDonationAmount, + verifiedDonationAmount1 + verifiedDonationAmount2, + ); + }); + + it('should return correct ea and qf donation amounts', async () => { + const ea1 = await saveEARoundDirectlyToDb({ + roundNumber: generateEARoundNumber(), + startDate: new Date('2024-09-01'), + endDate: new Date('2024-09-05'), + }); + const ea2 = await saveEARoundDirectlyToDb({ + roundNumber: generateEARoundNumber(), + startDate: new Date('2024-09-06'), + endDate: new Date('2024-09-10'), + }); + + const qfRound = await QfRound.create({ + isActive: true, + name: 'test qf ', + allocatedFund: 100, + minimumPassportScore: 8, + slug: 'QF - 2024-09-10 - ' + generateQfRoundNumber(), + beginDate: moment('2024-09-10').add(1, 'days').toDate(), + endDate: moment('2024-09-10').add(10, 'days').toDate(), + }).save(); + + const ea1DonationAmount = 100; + const ea2DonationAmount = 200; + const qfDonationAmount = 400; + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: ea1DonationAmount, + status: DONATION_STATUS.VERIFIED, + earlyAccessRoundId: ea1.id, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: ea2DonationAmount, + status: DONATION_STATUS.VERIFIED, + earlyAccessRoundId: ea2.id, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: qfDonationAmount, + status: DONATION_STATUS.VERIFIED, + qfRoundId: qfRound.id, + }, + user.id, + project.id, + ); + + const userRecord = await updateOrCreateProjectUserRecord({ + projectId: project.id, + userId: user.id, + }); + + assert.isOk(userRecord); + assert.equal( + userRecord.eaTotalDonationAmount, + ea1DonationAmount + ea2DonationAmount, + ); + assert.equal(userRecord.qfTotalDonationAmount, qfDonationAmount); + assert.equal( + userRecord.totalDonationAmount, + ea1DonationAmount + ea2DonationAmount + qfDonationAmount, + ); + }); +}); diff --git a/src/repositories/projectUserRecordRepository.ts b/src/repositories/projectUserRecordRepository.ts new file mode 100644 index 000000000..98fdd6cb7 --- /dev/null +++ b/src/repositories/projectUserRecordRepository.ts @@ -0,0 +1,66 @@ +import { Donation, DONATION_STATUS } from '../entities/donation'; +import { ProjectUserRecord } from '../entities/projectUserRecord'; + +export async function updateOrCreateProjectUserRecord({ + projectId, + userId, +}: { + projectId: number; + userId: number; +}): Promise { + const { eaTotalDonationAmount, qfTotalDonationAmount, totalDonationAmount } = + await Donation.createQueryBuilder('donation') + .select('SUM(donation.amount)', 'totalDonationAmount') + // sum eaTotalDonationAmount if earlyAccessRoundId is not null + .addSelect( + 'SUM(CASE WHEN donation.earlyAccessRoundId IS NOT NULL THEN donation.amount ELSE 0 END)', + 'eaTotalDonationAmount', + ) + .addSelect( + 'SUM(CASE WHEN donation.qfRoundId IS NOT NULL THEN donation.amount ELSE 0 END)', + 'qfTotalDonationAmount', + ) + .where('donation.projectId = :projectId', { projectId }) + .andWhere('donation.status = :status', { + status: DONATION_STATUS.VERIFIED, + }) + .andWhere('donation.userId = :userId', { userId }) + .getRawOne(); + + let projectUserRecord = await ProjectUserRecord.findOneBy({ + projectId, + userId, + }); + + if (!projectUserRecord) { + projectUserRecord = ProjectUserRecord.create({ + projectId, + userId, + }); + } + + projectUserRecord.eaTotalDonationAmount = eaTotalDonationAmount || 0; + projectUserRecord.qfTotalDonationAmount = qfTotalDonationAmount || 0; + projectUserRecord.totalDonationAmount = totalDonationAmount || 0; + + return projectUserRecord.save(); +} + +export type ProjectUserRecordAmounts = Pick< + ProjectUserRecord, + 'totalDonationAmount' | 'eaTotalDonationAmount' | 'qfTotalDonationAmount' +>; +export async function getProjectUserRecordAmount({ + projectId, + userId, +}: { + projectId: number; + userId: number; +}): Promise { + const record = await ProjectUserRecord.findOneBy({ projectId, userId }); + return { + totalDonationAmount: record?.totalDonationAmount || 0, + eaTotalDonationAmount: record?.eaTotalDonationAmount || 0, + qfTotalDonationAmount: record?.qfTotalDonationAmount || 0, + }; +} diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 099e9be04..04dd50b75 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -4,6 +4,7 @@ import { ArgumentValidationError } from 'type-graphql'; import { createProjectData, deleteProjectDirectlyFromDb, + generateEARoundNumber, generateRandomEtheriumAddress, generateTestAccessToken, graphqlUrl, @@ -1290,7 +1291,7 @@ function getProjectRoundRecordsTestCases() { // Create Early Access Round (Assuming you have such an entity) earlyAccessRoundId = ( await EarlyAccessRound.create({ - roundNumber: 1, + roundNumber: generateEARoundNumber(), startDate: new Date('2024-09-01'), endDate: new Date('2024-09-05'), }).save() diff --git a/src/resolvers/roundsResolver.test.ts b/src/resolvers/roundsResolver.test.ts index 99bb712f9..45faf1248 100644 --- a/src/resolvers/roundsResolver.test.ts +++ b/src/resolvers/roundsResolver.test.ts @@ -2,7 +2,11 @@ import { assert } from 'chai'; import moment from 'moment'; import axios from 'axios'; import { AppDataSource } from '../orm'; -import { graphqlUrl } from '../../test/testUtils'; +import { + generateEARoundNumber, + generateQfRoundNumber, + graphqlUrl, +} from '../../test/testUtils'; import { QfRound } from '../entities/qfRound'; import { EarlyAccessRound } from '../entities/earlyAccessRound'; import { generateRandomString } from '../utils/utils'; @@ -52,13 +56,13 @@ function fetchAllRoundsTestCases() { it('should return all rounds (QF Rounds and Early Access Rounds)', async () => { // Create Early Access Rounds const earlyAccessRound1 = await EarlyAccessRound.create({ - roundNumber: 1, + roundNumber: generateEARoundNumber(), startDate: new Date(), endDate: moment().add(3, 'days').toDate(), }).save(); const earlyAccessRound2 = await EarlyAccessRound.create({ - roundNumber: 2, + roundNumber: generateEARoundNumber(), startDate: moment().add(4, 'days').toDate(), endDate: moment().add(7, 'days').toDate(), }).save(); @@ -67,6 +71,7 @@ function fetchAllRoundsTestCases() { const qfRound1 = await QfRound.create({ name: 'QF Round 1', slug: generateRandomString(10), + roundNumber: generateQfRoundNumber(), allocatedFund: 100000, minimumPassportScore: 8, beginDate: new Date(), @@ -145,7 +150,7 @@ function fetchActiveRoundTestCases() { it('should return the currently active Early Access round and no active QF round', async () => { // Create an active Early Access Round const activeEarlyAccessRound = await EarlyAccessRound.create({ - roundNumber: 1, + roundNumber: generateEARoundNumber(), startDate: moment().subtract(1, 'days').toDate(), endDate: moment().add(2, 'days').toDate(), }).save(); @@ -179,7 +184,7 @@ function fetchActiveRoundTestCases() { it('should return the currently active QF round and no active Early Access round', async () => { // Create a non-active Early Access Round await EarlyAccessRound.create({ - roundNumber: 2, + roundNumber: generateEARoundNumber(), startDate: moment().add(10, 'days').toDate(), endDate: moment().add(20, 'days').toDate(), }).save(); @@ -210,7 +215,7 @@ function fetchActiveRoundTestCases() { it('should return null when there are no active rounds', async () => { // Create a non-active Early Access Round await EarlyAccessRound.create({ - roundNumber: 2, + roundNumber: generateEARoundNumber(), startDate: moment().add(10, 'days').toDate(), endDate: moment().add(20, 'days').toDate(), }).save(); diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index 78ceced4b..f9050b230 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -1,16 +1,20 @@ // TODO Write test cases -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { assert } from 'chai'; import sinon from 'sinon'; +import { ExecutionResult } from 'graphql'; +import moment from 'moment'; import { User } from '../entities/user'; import { createDonationData, createProjectData, + generateEARoundNumber, generateRandomEtheriumAddress, generateTestAccessToken, graphqlUrl, saveDonationDirectlyToDb, + saveEARoundDirectlyToDb, saveProjectDirectlyToDb, saveUserDirectlyToDb, SEED_DATA, @@ -19,6 +23,7 @@ import { acceptedTermsOfService, batchMintingEligibleUsers, checkUserPrivadoVerifiedState, + projectUserTotalDonationAmounts, refreshUserScores, updateUser, userByAddress, @@ -32,6 +37,11 @@ import { updateUserTotalDonated } from '../services/userService'; import { getUserEmailConfirmationFields } from '../repositories/userRepository'; import { UserEmailVerification } from '../entities/userEmailVerification'; import { PrivadoAdapter } from '../adapters/privado/privadoAdapter'; +import { + ProjectUserRecordAmounts, + updateOrCreateProjectUserRecord, +} from '../repositories/projectUserRecordRepository'; +import { QfRound } from '../entities/qfRound'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); @@ -59,6 +69,11 @@ describe( batchMintingEligibleUsersTestCases, ); +describe( + 'projectUserTotalDonationAmount() test cases', + projectUserTotalDonationAmountTestCases, +); + // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -1292,3 +1307,94 @@ function batchMintingEligibleUsersTestCases() { ]); }); } + +function projectUserTotalDonationAmountTestCases() { + it('should return total donation amount of a user for a project', async () => { + it('should return total donation amount of a user for a project', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const project = await saveProjectDirectlyToDb(createProjectData()); + + const ea1 = await saveEARoundDirectlyToDb({ + roundNumber: generateEARoundNumber(), + startDate: new Date('2024-09-01'), + endDate: new Date('2024-09-05'), + }); + const ea2 = await saveEARoundDirectlyToDb({ + roundNumber: generateEARoundNumber(), + startDate: new Date('2024-09-06'), + endDate: new Date('2024-09-10'), + }); + + const qfRoundNumber = generateEARoundNumber(); + const qfRound = await QfRound.create({ + isActive: true, + name: 'test qf ', + allocatedFund: 100, + minimumPassportScore: 8, + slug: 'QF - 2024-09-10 - ' + qfRoundNumber, + roundNumber: qfRoundNumber, + beginDate: moment('2024-09-10').add(1, 'days').toDate(), + endDate: moment('2024-09-10').add(10, 'days').toDate(), + }).save(); + + const ea1DonationAmount = 100; + const ea2DonationAmount = 200; + const qfDonationAmount = 400; + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: ea1DonationAmount, + status: DONATION_STATUS.VERIFIED, + earlyAccessRoundId: ea1.id, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: ea2DonationAmount, + status: DONATION_STATUS.VERIFIED, + earlyAccessRoundId: ea2.id, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: qfDonationAmount, + status: DONATION_STATUS.VERIFIED, + qfRoundId: qfRound.id, + }, + user.id, + project.id, + ); + await updateOrCreateProjectUserRecord({ + projectId: project.id, + userId: user.id, + }); + + const result: AxiosResponse< + ExecutionResult<{ + projectUserTotalDonationAmounts: ProjectUserRecordAmounts; + }> + > = await axios.post(graphqlUrl, { + query: projectUserTotalDonationAmounts, + variables: { + projectId: project.id, + userId: user.id, + }, + }); + + assert.isOk(result.data); + assert.deepEqual(result.data.data?.projectUserTotalDonationAmounts, { + eaTotalDonationAmount: ea1DonationAmount + ea2DonationAmount, + qfTotalDonationAmount: qfDonationAmount, + totalDonationAmount: + ea1DonationAmount + ea2DonationAmount + qfDonationAmount, + }); + }); + }); +} diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 133e20e8c..0b4543625 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -2,6 +2,7 @@ import { Arg, Ctx, Field, + Float, Int, Mutation, ObjectType, @@ -36,6 +37,7 @@ import { addressHasDonated } from '../repositories/donationRepository'; // import { getOrttoPersonAttributes } from '../adapters/notifications/NotificationCenterAdapter'; import { retrieveActiveQfRoundUserMBDScore } from '../repositories/qfRoundRepository'; import { PrivadoAdapter } from '../adapters/privado/privadoAdapter'; +import { getProjectUserRecordAmount } from '../repositories/projectUserRecordRepository'; @ObjectType() class UserRelatedAddressResponse { @@ -58,6 +60,18 @@ class BatchMintingEligibleUserResponse { skip: number; } +@ObjectType() +class ProjectUserRecordAmounts { + @Field(_type => Float) + totalDonationAmount: number; + + @Field(_type => Float) + eaTotalDonationAmount: number; + + @Field(_type => Float) + qfTotalDonationAmount: number; +} + // eslint-disable-next-line unused-imports/no-unused-imports @Resolver(_of => User) export class UserResolver { @@ -451,4 +465,17 @@ export class UserResolver { } return false; } + + @Query(_returns => ProjectUserRecordAmounts) + async projectUserTotalDonationAmounts( + @Arg('projectId', _type => Int, { nullable: false }) projectId: number, + @Arg('userId', _type => Int, { nullable: false }) userId: number, + ) { + const record = await getProjectUserRecordAmount({ projectId, userId }); + return { + totalDonationAmount: record.totalDonationAmount, + eaTotalDonationAmount: record.eaTotalDonationAmount, + qfTotalDonationAmount: record.qfTotalDonationAmount, + }; + } } diff --git a/src/services/donationService.ts b/src/services/donationService.ts index bee49db9a..6083354a0 100644 --- a/src/services/donationService.ts +++ b/src/services/donationService.ts @@ -40,6 +40,7 @@ import { getOrttoPersonAttributes } from '../adapters/notifications/Notification import { CustomToken, getTokenPrice } from './priceService'; import { updateProjectStatistics } from './projectService'; import { updateOrCreateProjectRoundRecord } from '../repositories/projectRoundRecordRepository'; +import { updateOrCreateProjectUserRecord } from '../repositories/projectUserRecordRepository'; export const TRANSAK_COMPLETED_STATUS = 'COMPLETED'; @@ -272,6 +273,10 @@ export const syncDonationStatusWithBlockchainNetwork = async (params: { donation.qfRoundId, donation.earlyAccessRoundId, ); + await updateOrCreateProjectUserRecord({ + projectId: donation.projectId, + userId: donation.userId, + }); await sendNotificationForDonation({ donation, diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 04fa12ee9..cb1710ca1 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2127,3 +2127,13 @@ export const getProjectRoundRecordsQuery = ` } } `; + +export const projectUserTotalDonationAmounts = ` + query ProjectUserTotalDonationAmounts($projectId: Int!, $userId: Int!) { + projectUserTotalDonationAmounts(projectId: $projectId, userId: $userId) { + totalDonationAmount + eaTotalDonationAmount + qfTotalDonationAmount + } + } +`; diff --git a/test/testUtils.ts b/test/testUtils.ts index 84f62d0ec..4a723bfba 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -2059,7 +2059,7 @@ export function generateRandomSolanaTxHash() { // list of test cases titles that doesn't require DB interaction export const dbIndependentTests = ['AdminJsPermissions']; -export const saveRoundDirectlyToDb = async ( +export const saveEARoundDirectlyToDb = async ( roundData: Partial, ): Promise => { const round = EarlyAccessRound.create(roundData) as EarlyAccessRound; @@ -2070,3 +2070,7 @@ let nextQfRoundNumber = 1000; export function generateQfRoundNumber(): number { return nextQfRoundNumber++; } +let nextEARoundNumber = 1000; +export function generateEARoundNumber(): number { + return nextEARoundNumber++; +}