diff --git a/config/example.env b/config/example.env index 60f417318..fbfc9a209 100644 --- a/config/example.env +++ b/config/example.env @@ -278,5 +278,4 @@ ABC_LAUNCH_DATA_SOURCE= QACC_NETWORK_ID= QACC_DONATION_TOKEN_ADDRESS= -QACC_EARLY_ACCESS_ROUND_FINISH_TIMESTAMP= ABC_LAUNCHER_ADAPTER= \ No newline at end of file diff --git a/migration/1724799772891-addEarlyAccessRoundTable.ts b/migration/1724799772891-addEarlyAccessRoundTable.ts new file mode 100644 index 000000000..39b424da8 --- /dev/null +++ b/migration/1724799772891-addEarlyAccessRoundTable.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEarlyAccessRoundTable1724799772891 + implements MigrationInterface +{ + name = 'AddEarlyAccessRoundTable1724799772891'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "donation" RENAME COLUMN "earlyAccessRound" TO "earlyAccessRoundId"`, + ); + await queryRunner.query( + `CREATE TABLE "early_access_round" ("id" SERIAL NOT NULL, "roundNumber" integer NOT NULL, "startDate" TIMESTAMP NOT NULL, "endDate" TIMESTAMP NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_e2f9598b0bbed3f05ca5c49fedc" UNIQUE ("roundNumber"), CONSTRAINT "PK_b128520615d2666c576399b07d3" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "donation" DROP COLUMN "earlyAccessRoundId"`, + ); + await queryRunner.query( + `ALTER TABLE "donation" ADD "earlyAccessRoundId" integer`, + ); + await queryRunner.query( + `ALTER TABLE "donation" ADD CONSTRAINT "FK_635e96839361920b7f80da1dd51" FOREIGN KEY ("earlyAccessRoundId") REFERENCES "early_access_round"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "donation" DROP CONSTRAINT "FK_635e96839361920b7f80da1dd51"`, + ); + await queryRunner.query( + `ALTER TABLE "donation" DROP COLUMN "earlyAccessRoundId"`, + ); + await queryRunner.query( + `ALTER TABLE "donation" ADD "earlyAccessRoundId" boolean DEFAULT false`, + ); + await queryRunner.query(`DROP TABLE "early_access_round"`); + await queryRunner.query( + `ALTER TABLE "donation" RENAME COLUMN "earlyAccessRoundId" TO "earlyAccessRound"`, + ); + } +} diff --git a/package.json b/package.json index fdfb30aee..f50a0ae7c 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,8 @@ "test:projectResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/projectResolver.test.ts ./src/resolvers/projectResolver.allProject.test.ts", "test:chainvineResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/chainvineResolver.test.ts", "test:qfRoundResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/qfRoundResolver.test.ts", + "test:earlyAccessRoundRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/earlyAccessRoundRepository.test.ts", + "test:earlyAccessRoundResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/earlyAccessRoundResolver.test.ts", "test:qfRoundHistoryResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/qfRoundHistoryResolver.test.ts", "test:projectVerificationResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/projectVerificationFormResolver.test.ts", "test:projectRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/projectRepository.test.ts", diff --git a/src/entities/donation.ts b/src/entities/donation.ts index e11b3f600..ed937e362 100644 --- a/src/entities/donation.ts +++ b/src/entities/donation.ts @@ -12,6 +12,7 @@ import { Project } from './project'; import { User } from './user'; import { QfRound } from './qfRound'; import { ChainType } from '../types/network'; +import { EarlyAccessRound } from './earlyAccessRound'; export const DONATION_STATUS = { PENDING: 'pending', @@ -257,9 +258,13 @@ export class Donation extends BaseEntity { @Column('decimal', { precision: 5, scale: 2, nullable: true }) donationPercentage?: number; - @Field(_type => Boolean, { nullable: false }) - @Column({ nullable: true, default: false }) - earlyAccessRound: boolean; + @Field(_type => EarlyAccessRound, { nullable: true }) + @ManyToOne(_type => EarlyAccessRound, { eager: true, nullable: true }) + earlyAccessRound: EarlyAccessRound | null; + + @RelationId((donation: Donation) => donation.earlyAccessRound) + @Column({ nullable: true }) + earlyAccessRoundId: number; static async findXdaiGivDonationsWithoutPrice() { return this.createQueryBuilder('donation') diff --git a/src/entities/earlyAccessRound.ts b/src/entities/earlyAccessRound.ts new file mode 100644 index 000000000..babf6029f --- /dev/null +++ b/src/entities/earlyAccessRound.ts @@ -0,0 +1,37 @@ +import { + BaseEntity, + Column, + Entity, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Field, ID, ObjectType, Int } from 'type-graphql'; + +@Entity() +@ObjectType() +export class EarlyAccessRound extends BaseEntity { + @Field(_type => ID) + @PrimaryGeneratedColumn() + id: number; + + @Field(() => Int) + @Column({ unique: true }) + roundNumber: number; + + @Field(() => Date) + @Column() + startDate: Date; + + @Field(() => Date) + @Column() + endDate: Date; + + @Field(() => Date) + @CreateDateColumn() + createdAt: Date; + + @Field(() => Date) + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entities/entities.ts b/src/entities/entities.ts index cae0aa133..1cd4c415a 100644 --- a/src/entities/entities.ts +++ b/src/entities/entities.ts @@ -32,6 +32,7 @@ import { ProjectActualMatchingView } from './ProjectActualMatchingView'; import { ProjectSocialMedia } from './projectSocialMedia'; import { UserQfRoundModelScore } from './userQfRoundModelScore'; import { UserEmailVerification } from './userEmailVerification'; +import { EarlyAccessRound } from './earlyAccessRound'; export const getEntities = (): DataSourceOptions['entities'] => { return [ @@ -76,5 +77,6 @@ export const getEntities = (): DataSourceOptions['entities'] => { Sybil, ProjectFraud, UserQfRoundModelScore, + EarlyAccessRound, ]; }; diff --git a/src/repositories/earlyAccessRoundRepository.test.ts b/src/repositories/earlyAccessRoundRepository.test.ts new file mode 100644 index 000000000..94009b7f2 --- /dev/null +++ b/src/repositories/earlyAccessRoundRepository.test.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai'; +import { EarlyAccessRound } from '../entities/earlyAccessRound'; +import { + findAllEarlyAccessRounds, + findActiveEarlyAccessRound, +} from './earlyAccessRoundRepository'; +import { saveRoundDirectlyToDb } from '../../test/testUtils'; + +describe('EarlyAccessRound Repository Test Cases', () => { + beforeEach(async () => { + // Clean up data before each test case + await EarlyAccessRound.delete({}); + }); + + afterEach(async () => { + // Clean up data after each test case + await EarlyAccessRound.delete({}); + }); + + it('should save a new Early Access Round directly to the database', async () => { + const roundData = { + roundNumber: 1, + startDate: new Date('2024-09-01'), + endDate: new Date('2024-09-05'), + }; + + const savedRound = await saveRoundDirectlyToDb(roundData); + + expect(savedRound).to.be.an.instanceof(EarlyAccessRound); + expect(savedRound.roundNumber).to.equal(roundData.roundNumber); + expect(savedRound.startDate.toISOString()).to.equal( + roundData.startDate.toISOString(), + ); + expect(savedRound.endDate.toISOString()).to.equal( + roundData.endDate.toISOString(), + ); + }); + + it('should find all Early Access Rounds', async () => { + // Save a couple of rounds first + await saveRoundDirectlyToDb({ + roundNumber: 1, + startDate: new Date('2024-09-01'), + endDate: new Date('2024-09-05'), + }); + await saveRoundDirectlyToDb({ + roundNumber: 2, + startDate: new Date('2024-09-06'), + endDate: new Date('2024-09-10'), + }); + + const rounds = await findAllEarlyAccessRounds(); + + expect(rounds).to.be.an('array'); + expect(rounds.length).to.equal(2); + expect(rounds[0]).to.be.an.instanceof(EarlyAccessRound); + expect(rounds[1]).to.be.an.instanceof(EarlyAccessRound); + }); + + it('should find the active Early Access Round', async () => { + const activeRoundData = { + roundNumber: 1, + 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, + 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); + + const activeRound = await findActiveEarlyAccessRound(); + + expect(activeRound).to.be.an.instanceof(EarlyAccessRound); + expect(activeRound?.roundNumber).to.equal(activeRoundData.roundNumber); + expect(activeRound?.startDate.toISOString()).to.equal( + activeRoundData.startDate.toISOString(), + ); + expect(activeRound?.endDate.toISOString()).to.equal( + activeRoundData.endDate.toISOString(), + ); + }); + + it('should return null when no active Early Access Round is found', async () => { + const activeRound = await findActiveEarlyAccessRound(); + expect(activeRound).to.be.null; + }); +}); diff --git a/src/repositories/earlyAccessRoundRepository.ts b/src/repositories/earlyAccessRoundRepository.ts new file mode 100644 index 000000000..e0cb57c3d --- /dev/null +++ b/src/repositories/earlyAccessRoundRepository.ts @@ -0,0 +1,32 @@ +import { EarlyAccessRound } from '../entities/earlyAccessRound'; +import { logger } from '../utils/logger'; + +export const findAllEarlyAccessRounds = async (): Promise< + EarlyAccessRound[] +> => { + try { + return EarlyAccessRound.createQueryBuilder('earlyAccessRound') + .orderBy('earlyAccessRound.startDate', 'ASC') + .getMany(); + } catch (error) { + logger.error('Error fetching all Early Access rounds', { error }); + throw new Error('Error fetching Early Access rounds'); + } +}; + +// Find the currently active Early Access Round +export const findActiveEarlyAccessRound = + async (): Promise => { + const currentDate = new Date(); + + try { + const query = EarlyAccessRound.createQueryBuilder('earlyAccessRound') + .where('earlyAccessRound.startDate <= :currentDate', { currentDate }) + .andWhere('earlyAccessRound.endDate >= :currentDate', { currentDate }); + + return query.getOne(); + } catch (error) { + logger.error('Error fetching active Early Access round', { error }); + throw new Error('Error fetching active Early Access round'); + } + }; diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index c8c5b1678..ae567dc98 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -922,7 +922,7 @@ function createDonationTestCases() { assert.equal(donation?.referrerWallet, user2.walletAddress); assert.isOk(donation?.referralStartTimestamp); assert.isNotOk(donation?.qfRound); - assert.isTrue(donation?.earlyAccessRound); + // assert.isTrue(donation?.earlyAccessRound); }); it('should create a donation in an active qfRound', async () => { sinon.stub(qacc, 'isEarlyAccessRound').returns(false); diff --git a/src/resolvers/donationResolver.ts b/src/resolvers/donationResolver.ts index 8d76a09eb..8cff999bd 100644 --- a/src/resolvers/donationResolver.ts +++ b/src/resolvers/donationResolver.ts @@ -71,6 +71,7 @@ import { DraftDonation, } from '../entities/draftDonation'; import qacc from '../utils/qacc'; +import { findActiveEarlyAccessRound } from '../repositories/earlyAccessRoundRepository'; const draftDonationEnabled = process.env.ENABLE_DRAFT_DONATION === 'true'; @ObjectType() @@ -875,7 +876,7 @@ export class DonationResolver { logger.error('get chainvine wallet address error', e); } } - if (!qacc.isEarlyAccessRound()) { + if (!(await qacc.isEarlyAccessRound())) { const activeQfRoundForProject = await relatedActiveQfRoundForProject(projectId); if ( @@ -903,7 +904,7 @@ export class DonationResolver { } await donation.save(); } else { - donation.earlyAccessRound = true; + donation.earlyAccessRound = await findActiveEarlyAccessRound(); await donation.save(); } let priceChainId; diff --git a/src/resolvers/earlyAccessRoundResolver.test.ts b/src/resolvers/earlyAccessRoundResolver.test.ts new file mode 100644 index 000000000..765d6ff7f --- /dev/null +++ b/src/resolvers/earlyAccessRoundResolver.test.ts @@ -0,0 +1,102 @@ +import { assert } from 'chai'; +import axios from 'axios'; +import moment from 'moment'; +import { saveRoundDirectlyToDb, graphqlUrl } from '../../test/testUtils'; +import { + fetchAllEarlyAccessRoundsQuery, + fetchActiveEarlyAccessRoundQuery, +} from '../../test/graphqlQueries'; +import { EarlyAccessRound } from '../entities/earlyAccessRound'; + +describe( + 'Fetch all Early Access Rounds test cases', + fetchAllEarlyAccessRoundsTestCases, +); +describe( + 'Fetch active Early Access Round test cases', + fetchActiveEarlyAccessRoundTestCases, +); + +function fetchAllEarlyAccessRoundsTestCases() { + beforeEach(async () => { + // Clean up data before each test case + await EarlyAccessRound.delete({}); + }); + + afterEach(async () => { + // Clean up data after each test case + await EarlyAccessRound.delete({}); + }); + + it('should return all early access rounds', async () => { + // Create some rounds with specific dates + const round1 = await saveRoundDirectlyToDb({ + roundNumber: 1, + startDate: new Date(), + endDate: moment().add(3, 'days').toDate(), + }); + + const round2 = await saveRoundDirectlyToDb({ + roundNumber: 2, + startDate: moment().add(4, 'days').toDate(), + endDate: moment().add(7, 'days').toDate(), + }); + + const result = await axios.post(graphqlUrl, { + query: fetchAllEarlyAccessRoundsQuery, + }); + + const rounds = result.data.data.allEarlyAccessRounds; + assert.isArray(rounds); + assert.lengthOf(rounds, 2); + assert.equal(rounds[0].roundNumber, round1.roundNumber); + assert.equal(rounds[1].roundNumber, round2.roundNumber); + }); +} + +function fetchActiveEarlyAccessRoundTestCases() { + beforeEach(async () => { + // Clean up data before each test case + await EarlyAccessRound.delete({}); + }); + + afterEach(async () => { + // Clean up data after each test case + await EarlyAccessRound.delete({}); + }); + + it('should return the currently active early access round', async () => { + // Create an active round + const activeRound = await saveRoundDirectlyToDb({ + roundNumber: 1, + startDate: moment().subtract(1, 'days').toDate(), + endDate: moment().add(2, 'days').toDate(), + }); + + const result = await axios.post(graphqlUrl, { + query: fetchActiveEarlyAccessRoundQuery, + }); + + const round = result.data.data.activeEarlyAccessRound; + assert.isOk(round); + assert.equal(round.roundNumber, activeRound.roundNumber); + assert.isTrue(new Date(round.startDate) < new Date()); + assert.isTrue(new Date(round.endDate) > new Date()); + }); + + it('should return null if there is no active early access round', async () => { + // Create a round that is not active + await saveRoundDirectlyToDb({ + roundNumber: 2, + startDate: moment().add(10, 'days').toDate(), + endDate: moment().add(20, 'days').toDate(), + }); + + const result = await axios.post(graphqlUrl, { + query: fetchActiveEarlyAccessRoundQuery, + }); + + const round = result.data.data.activeEarlyAccessRound; + assert.isNull(round); + }); +} diff --git a/src/resolvers/earlyAccessRoundResolver.ts b/src/resolvers/earlyAccessRoundResolver.ts new file mode 100644 index 000000000..f83a67788 --- /dev/null +++ b/src/resolvers/earlyAccessRoundResolver.ts @@ -0,0 +1,53 @@ +import { Field, ObjectType, Query, Resolver } from 'type-graphql'; +import { Service } from 'typedi'; +import { EarlyAccessRound } from '../entities/earlyAccessRound'; +import { + findActiveEarlyAccessRound, + findAllEarlyAccessRounds, +} from '../repositories/earlyAccessRoundRepository'; +import { logger } from '../utils/logger'; + +@Service() +@ObjectType() +class EarlyAccessRoundResponse { + @Field() + roundNumber: number; + + @Field() + startDate: Date; + + @Field() + endDate: Date; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; +} + +@Resolver(_of => EarlyAccessRound) +export class EarlyAccessRoundResolver { + // Fetches all Early Access Rounds + @Query(_returns => [EarlyAccessRoundResponse], { nullable: true }) + async allEarlyAccessRounds(): Promise { + try { + return await findAllEarlyAccessRounds(); + } catch (error) { + logger.error('Error fetching all Early Access Rounds:', error); + throw new Error('Could not fetch all Early Access Rounds.'); + } + } + + // Fetches the currently active Early Access Round + @Query(_returns => EarlyAccessRoundResponse, { nullable: true }) + async activeEarlyAccessRound(): Promise { + try { + const activeRound = await findActiveEarlyAccessRound(); + return activeRound || null; + } catch (error) { + logger.error('Error fetching active Early Access Round:', error); + throw new Error('Could not fetch active Early Access Round.'); + } + } +} diff --git a/src/resolvers/resolvers.ts b/src/resolvers/resolvers.ts index 71867353f..41de01846 100644 --- a/src/resolvers/resolvers.ts +++ b/src/resolvers/resolvers.ts @@ -14,6 +14,7 @@ import { QfRoundResolver } from './qfRoundResolver'; import { QfRoundHistoryResolver } from './qfRoundHistoryResolver'; import { DraftDonationResolver } from './draftDonationResolver'; import { OnboardingFormResolver } from './onboardingFormResolver'; +import { EarlyAccessRoundResolver } from './earlyAccessRoundResolver'; // eslint-disable-next-line @typescript-eslint/ban-types export const getResolvers = (): Function[] => { @@ -37,5 +38,6 @@ export const getResolvers = (): Function[] => { QfRoundHistoryResolver, OnboardingFormResolver, + EarlyAccessRoundResolver, ]; }; diff --git a/src/utils/qacc.ts b/src/utils/qacc.ts index 49d02b62b..4450dc78c 100644 --- a/src/utils/qacc.ts +++ b/src/utils/qacc.ts @@ -2,12 +2,9 @@ import { getAbcLauncherAdapter } from '../adapters/adaptersFactory'; import config from '../config'; import { Project } from '../entities/project'; import { i18n, translationErrorMessagesKeys } from './errorMessages'; -import { logger } from './logger'; import { QACC_NETWORK_ID } from '../provider'; +import { findActiveEarlyAccessRound } from '../repositories/earlyAccessRoundRepository'; -const QACC_EARLY_ACCESS_ROUND_FINISH_TIMESTAMP = config.get( - 'QACC_EARLY_ACCESS_ROUND_FINISH_TIMESTAMP', -) as number; export const QACC_DONATION_TOKEN_ADDRESS: string = (config.get('QACC_DONATION_TOKEN_ADDRESS') as string) || '0xa2036f0538221a77A3937F1379699f44945018d0'; //https://zkevm.polygonscan.com/token/0xa2036f0538221a77a3937f1379699f44945018d0#readContract @@ -20,18 +17,9 @@ export const QACC_DONATION_TOKEN_DECIMALS = export const QACC_DONATION_TOKEN_COINGECKO_ID = (config.get('QACC_DONATION_TOKEN_COINGECKO_ID') as string) || 'matic-network'; -if (!QACC_EARLY_ACCESS_ROUND_FINISH_TIMESTAMP) { - logger.error('QACC_EARLY_ACCESS_ROUND_FINISH_TIMESTAMP is not set'); -} - -const _isEarlyAccessRound = (earlyAccessRoundFinishTime: number): boolean => { - return Date.now() / 1000 < earlyAccessRoundFinishTime; -}; - -const isEarlyAccessRound = () => { - return _isEarlyAccessRound( - QACC_EARLY_ACCESS_ROUND_FINISH_TIMESTAMP || Number.MAX_SAFE_INTEGER, - ); +const isEarlyAccessRound = async () => { + const earlyAccessRound = await findActiveEarlyAccessRound(); + return !!earlyAccessRound; }; const validateDonation = async (params: { @@ -50,7 +38,7 @@ const validateDonation = async (params: { i18n.__(translationErrorMessagesKeys.INVALID_TOKEN_ADDRESS), ); } - if (isEarlyAccessRound()) { + if (await isEarlyAccessRound()) { const [project] = (await Project.query('select abc from project where id=$1', [ projectId, diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index aaad32174..2d504afb0 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2038,3 +2038,27 @@ export const userVerificationConfirmEmail = ` } } `; + +export const fetchAllEarlyAccessRoundsQuery = ` + query { + allEarlyAccessRounds { + roundNumber + startDate + endDate + createdAt + updatedAt + } + } +`; + +export const fetchActiveEarlyAccessRoundQuery = ` + query { + activeEarlyAccessRound { + roundNumber + startDate + endDate + createdAt + updatedAt + } + } +`; diff --git a/test/testUtils.ts b/test/testUtils.ts index 4b84f5818..165ceef22 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -41,6 +41,7 @@ import { QACC_DONATION_TOKEN_NAME, QACC_DONATION_TOKEN_SYMBOL, } from '../src/utils/qacc'; +import { EarlyAccessRound } from '../src/entities/earlyAccessRound'; // eslint-disable-next-line @typescript-eslint/no-var-requires const moment = require('moment'); @@ -2046,3 +2047,10 @@ export function generateRandomSolanaTxHash() { // list of test cases titles that doesn't require DB interaction export const dbIndependentTests = ['AdminJsPermissions']; + +export const saveRoundDirectlyToDb = async ( + roundData: Partial, +): Promise => { + const round = EarlyAccessRound.create(roundData) as EarlyAccessRound; + return round.save(); +};