diff --git a/.github/workflows/main-deploy-only.yml b/.github/workflows/main-deploy-only.yml index 5e344db4d..ff3b626e0 100644 --- a/.github/workflows/main-deploy-only.yml +++ b/.github/workflows/main-deploy-only.yml @@ -16,7 +16,7 @@ jobs: port: ${{ secrets.SSH_PORT }} script: | cd QAcc-BE - git reset --hard HEAD~1 + git reset --hard origin/main git checkout main git pull origin main docker image prune -a --force diff --git a/.github/workflows/main-pipeline.yml b/.github/workflows/main-pipeline.yml index 2eae7af12..164089724 100644 --- a/.github/workflows/main-pipeline.yml +++ b/.github/workflows/main-pipeline.yml @@ -158,7 +158,7 @@ jobs: port: ${{ secrets.SSH_PORT }} script: | cd QAcc-BE - git reset --hard HEAD~1 + git reset --hard origin/main git checkout main git pull origin main docker image prune -a --force diff --git a/.github/workflows/main-publish-deploy.yml b/.github/workflows/main-publish-deploy.yml index cfda9ad63..a82556cfd 100644 --- a/.github/workflows/main-publish-deploy.yml +++ b/.github/workflows/main-publish-deploy.yml @@ -63,7 +63,7 @@ jobs: port: ${{ secrets.SSH_PORT }} script: | cd QAcc-BE - git reset --hard HEAD~1 + git reset --hard origin/main git checkout main git pull origin main docker image prune -a --force diff --git a/.github/workflows/staging-deploy-only.yml b/.github/workflows/staging-deploy-only.yml index 4f781712f..b7166465c 100644 --- a/.github/workflows/staging-deploy-only.yml +++ b/.github/workflows/staging-deploy-only.yml @@ -16,7 +16,7 @@ jobs: port: ${{ secrets.SSH_PORT }} script: | cd QAcc-BE - git reset --hard HEAD~1 + git reset --hard origin/staging git checkout staging git pull origin staging docker image prune -a --force diff --git a/.github/workflows/staging-pipeline.yml b/.github/workflows/staging-pipeline.yml index 29108aed5..f5ed50377 100644 --- a/.github/workflows/staging-pipeline.yml +++ b/.github/workflows/staging-pipeline.yml @@ -158,7 +158,7 @@ jobs: port: ${{ secrets.SSH_PORT }} script: | cd QAcc-BE - git reset --hard HEAD~1 + git reset --hard origin/staging git checkout staging git pull origin staging docker image prune -a --force diff --git a/migration/1734475040262-addMatchingFundsToProjectTable.ts b/migration/1734475040262-addMatchingFundsToProjectTable.ts new file mode 100644 index 000000000..fc27e9b4f --- /dev/null +++ b/migration/1734475040262-addMatchingFundsToProjectTable.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMatchingFundsToProjectTable1734475040262 + implements MigrationInterface +{ + name = 'AddMatchingFundsToProjectTable1734475040262'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project" ADD "matchingFunds" integer`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project" DROP COLUMN "matchingFunds"`, + ); + } +} diff --git a/migration/1734598837452-addProjectTributeModuleAddress.ts b/migration/1734598837452-addProjectTributeModuleAddress.ts new file mode 100644 index 000000000..119055f98 --- /dev/null +++ b/migration/1734598837452-addProjectTributeModuleAddress.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProjectTributeModuleAddress1734598837452 + implements MigrationInterface +{ + name = 'AddProjectTributeModuleAddress1734598837452'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project" ADD "tributeClaimModuleAddress" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project" DROP COLUMN "tributeClaimModuleAddress"`, + ); + } +} diff --git a/src/entities/project.ts b/src/entities/project.ts index eb685dc7b..7611445b9 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -259,6 +259,10 @@ export class Project extends BaseEntity { @Column('jsonb', { nullable: true }) abc: Abc; + @Field({ nullable: true }) + @Column({ nullable: true }) + tributeClaimModuleAddress: string; + @Index('trgm_idx_project_impact_location', { synchronize: false }) @Field({ nullable: true }) @Column({ nullable: true }) @@ -468,6 +472,10 @@ export class Project extends BaseEntity { @Column('integer', { array: true, default: [] }) batchNumbersWithSafeTransactions?: number[]; + @Field(_type => Int, { nullable: true }) + @Column({ type: 'int', nullable: true }) + matchingFunds?: number; + // only projects with status active can be listed automatically static pendingReviewSince(maximumDaysForListing: number) { const maxDaysForListing = moment() diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 7654e8ffb..162408c1f 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -73,6 +73,13 @@ function createProjectTestCases() { accessToken = await generateTestAccessToken(user.id); }); + afterEach(async () => { + const project = await Project.findOneBy({ adminUserId: user.id }); + if (project) { + await deleteProjectDirectlyFromDb(project.id); + } + }); + it('should create project with team members successfully', async () => { assert.isOk(user); assert.isOk(accessToken); @@ -328,10 +335,10 @@ function createProjectTestCases() { SEED_DATA.FOOD_SUB_CATEGORIES[1], ], description: '
Sample Project Creation
', - adminUserId: SEED_DATA.FIRST_USER.id, + adminUserId: user.id, address: generateRandomEtheriumAddress(), }; - const accessToken = await generateTestAccessToken(SEED_DATA.FIRST_USER.id); + const accessToken = await generateTestAccessToken(user.id); const result = await axios.post( graphqlUrl, { @@ -357,10 +364,10 @@ function createProjectTestCases() { title: String(new Date().getTime()), categories: [SEED_DATA.FOOD_SUB_CATEGORIES[0]], description: 'description', - adminUserId: SEED_DATA.FIRST_USER.id, + adminUserId: user.id, address: generateRandomEtheriumAddress(), }; - const accessToken = await generateTestAccessToken(SEED_DATA.FIRST_USER.id); + const accessToken = await generateTestAccessToken(user.id); const addProjectResponse = await axios.post( graphqlUrl, { @@ -390,10 +397,10 @@ function createProjectTestCases() { description: 'a'.repeat(PROJECT_DESCRIPTION_MAX_LENGTH + 1), image: 'https://gateway.pinata.cloud/ipfs/QmauSzWacQJ9rPkPJgr3J3pdgfNRGAaDCr1yAToVWev2QS', - adminUserId: SEED_DATA.FIRST_USER.id, + adminUserId: user.id, address: generateRandomEtheriumAddress(), }; - const accessToken = await generateTestAccessToken(SEED_DATA.FIRST_USER.id); + const accessToken = await generateTestAccessToken(user.id); let result = await axios.post( graphqlUrl, { @@ -443,10 +450,10 @@ function createProjectTestCases() { description: 'description', image: 'https://gateway.pinata.cloud/ipfs/QmauSzWacQJ9rPkPJgr3J3pdgfNRGAaDCr1yAToVWev2QS', - adminUserId: SEED_DATA.FIRST_USER.id, + adminUserId: user.id, address: generateRandomEtheriumAddress(), }; - const accessToken = await generateTestAccessToken(SEED_DATA.FIRST_USER.id); + const accessToken = await generateTestAccessToken(user.id); const result = await axios.post( graphqlUrl, { @@ -476,10 +483,7 @@ function createProjectTestCases() { ReviewStatus.Listed, ); - assert.equal( - result.data.data.createProject.adminUser.id, - SEED_DATA.FIRST_USER.id, - ); + assert.equal(result.data.data.createProject.adminUser.id, user.id); assert.equal(result.data.data.createProject.verified, false); assert.equal( result.data.data.createProject.status.id, @@ -492,7 +496,7 @@ function createProjectTestCases() { assert.equal( result.data.data.createProject.adminUser.walletAddress, - SEED_DATA.FIRST_USER.walletAddress, + user.walletAddress, ); assert.equal(result.data.data.createProject.image, sampleProject.image); assert.equal( diff --git a/src/resolvers/qAccResolver.test.ts b/src/resolvers/qAccResolver.test.ts index 27e08ba28..2a24b3d26 100644 --- a/src/resolvers/qAccResolver.test.ts +++ b/src/resolvers/qAccResolver.test.ts @@ -24,6 +24,7 @@ import { import { projectUserDonationCap, projectUserTotalDonationAmounts, + qAccStat, userCaps, } from '../../test/graphqlQueries'; import { ProjectRoundRecord } from '../entities/projectRoundRecord'; @@ -33,6 +34,7 @@ import { GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE, } from '../constants/gitcoin'; import { PrivadoAdapter } from '../adapters/privado/privadoAdapter'; +import { AppDataSource } from '../orm'; describe( 'projectUserTotalDonationAmount() test cases', @@ -46,6 +48,8 @@ describe( describe('userCaps() test cases', userCapsTestCases); +describe('qAccStat() test cases', qAccStatTestCases); + 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 () => { @@ -491,3 +495,96 @@ function userCapsTestCases() { } }); } + +function qAccStatTestCases() { + let project; + let user; + let qfRound1: QfRound; + beforeEach(async () => { + project = await saveProjectDirectlyToDb(createProjectData()); + user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + qfRound1 = await QfRound.create({ + roundNumber: 1, + isActive: true, + name: new Date().toString() + ' - 1', + allocatedFund: 100, + minimumPassportScore: 12, + slug: new Date().getTime().toString() + ' - 1', + beginDate: new Date('2001-01-14'), + endDate: new Date('2001-01-16'), + roundUSDCapPerProject: 10000, + roundUSDCapPerUserPerProject: 2500, + roundUSDCapPerUserPerProjectWithGitcoinScoreOnly: 1000, + tokenPrice: 0.5, + }).save(); + sinon.useFakeTimers({ + now: new Date('2001-01-15').getTime(), + }); + }); + afterEach(async () => { + // Clean up the database after each test + await ProjectRoundRecord.delete({}); + await Donation.delete({ projectId: project.id }); + await QfRound.delete(qfRound1.id); + + sinon.restore(); + }); + it('should return correct qacc stats', async () => { + const qfDonationAmount = Math.round(Math.random() * 1_000_000_00) / 100; + const nonQfDonationAmount = Math.round(Math.random() * 1_000_000_00) / 100; + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: nonQfDonationAmount, + status: DONATION_STATUS.VERIFIED, + }, + user.id, + project.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + amount: qfDonationAmount, + status: DONATION_STATUS.VERIFIED, + qfRoundId: qfRound1.id, + }, + user.id, + project.id, + ); + const result: AxiosResponse< + ExecutionResult<{ + qAccStat: { + totalCollected: number; + qfTotalCollected: number; + contributorsCount: number; + }; + }> + > = await axios.post(graphqlUrl, { + query: qAccStat, + }); + + assert.isOk(result.data); + + const dataSource = AppDataSource.getDataSource(); + const totalDonations = await dataSource.query(` + SELECT COALESCE(SUM(amount), 0) as totalCollected from donation where status = '${DONATION_STATUS.VERIFIED}' + `); + const qfTotalDonations = await dataSource.query(` + SELECT COALESCE(SUM(amount), 0) as qfTotalCollected from donation where status = '${DONATION_STATUS.VERIFIED}' AND "qfRoundId" IS NOT NULL + `); + // count unique contributors + const contributorsCount = await Donation.createQueryBuilder('donation') + .select('COUNT(DISTINCT "userId")', 'contributorsCount') + .where('donation.status = :status', { + status: DONATION_STATUS.VERIFIED, + }) + .getRawOne(); + + assert.deepEqual(result.data.data?.qAccStat, { + totalCollected: totalDonations[0].totalcollected, + qfTotalCollected: qfTotalDonations[0].qftotalcollected, + contributorsCount: +contributorsCount?.contributorsCount, + }); + }); +} diff --git a/src/resolvers/qAccResolver.ts b/src/resolvers/qAccResolver.ts index 6ffabf1d4..5f1bd075c 100644 --- a/src/resolvers/qAccResolver.ts +++ b/src/resolvers/qAccResolver.ts @@ -26,6 +26,18 @@ class ProjectUserRecordAmounts { qfTotalDonationAmount: number; } +@ObjectType() +class QaccStat { + @Field(_type => Float) + totalCollected: number; + + @Field(_type => Float) + qfTotalCollected: number; + + @Field(_type => Int) + contributorsCount: number; +} + @ObjectType() class UnusedCapResponse { @Field(_type => Float) @@ -118,4 +130,14 @@ export class QAccResolver { return response; } + + @Query(_returns => QaccStat) + async qAccStat() { + const state = await qAccService.getQAccStat(); + return { + totalCollected: state.totalCollected, + qfTotalCollected: state.qfTotalCollected, + contributorsCount: state.totalContributors, + }; + } } diff --git a/src/scripts/runFundingPotService.ts b/src/scripts/runFundingPotService.ts index 64296c62c..80c2e098c 100644 --- a/src/scripts/runFundingPotService.ts +++ b/src/scripts/runFundingPotService.ts @@ -59,7 +59,11 @@ async function generateBatchFile(batchNumber: number, dryRun: boolean) { ? round.cumulativeUSDCapPerUserPerProject : round.roundUSDCapPerUserPerProject) || '5000' ).toString(), // Default to 5000 for individual cap - INDIVIDUAL_2: isEarlyAccess ? '0' : '250', // Only required for QACC rounds + INDIVIDUAL_2: isEarlyAccess + ? '0' + : ( + round.roundUSDCapPerUserPerProjectWithGitcoinScoreOnly || '1000' + ).toString(), // Only required for QACC rounds if for users with GP score only TOTAL: ( (isEarlyAccess ? round.cumulativeUSDCapPerProject @@ -122,6 +126,7 @@ async function fillProjectsData() { SAFE: project.abc.projectAddress || '', ORCHESTRATOR: project.abc.orchestratorAddress || '', NFT: project.abc.nftContractAddress || '', + MATCHING_FUNDS: project.matchingFunds || '', }; } else { console.warn( @@ -175,7 +180,7 @@ async function createEnvFile() { 'ANKR_NETWORK_ID=polygon_zkevm', ) .replace( - 'RPC_URL="https://rpc.ankr.com/base_sepolia"', + 'RPC_URL="https://sepolia.base.org"', 'RPC_URL="https://zkevm-rpc.com"', ) .replace('CHAIN_ID=84532', 'CHAIN_ID=1101') diff --git a/src/services/qAccService.ts b/src/services/qAccService.ts index 0253711c6..97c3d3155 100644 --- a/src/services/qAccService.ts +++ b/src/services/qAccService.ts @@ -14,6 +14,7 @@ import { GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE, GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE, } from '../constants/gitcoin'; +import { Donation, DONATION_STATUS } from '../entities/donation'; const getEaProjectRoundRecord = async ({ projectId, @@ -270,8 +271,49 @@ const validDonationAmountBasedOnKYCAndScore = async ({ return true; }; +const getQAccStat = async (): Promise<{ + totalCollected: number; + qfTotalCollected: number; + totalContributors: number; +}> => { + const [qfTotalCollected, totalCollected, totalContributors] = + await Promise.all([ + Donation.createQueryBuilder('donation') + .select('COALESCE(sum(donation.amount), 0)', 'total_qf_collected') + .where('donation.status = :status', { + status: DONATION_STATUS.VERIFIED, + }) + .andWhere('donation."qfRoundId" IS NOT NULL') + .cache('qf_total_collected_donation', 1000) + .getRawOne(), + + Donation.createQueryBuilder('donation') + .select('COALESCE(sum(donation.amount), 0)', 'total_collected') + .where('donation.status = :status', { + status: DONATION_STATUS.VERIFIED, + }) + .cache('total_collected_donation', 1000) + .getRawOne(), + + Donation.createQueryBuilder('donation') + .select('count(distinct donation."userId")', 'total_contributors') + .where('donation.status = :status', { + status: DONATION_STATUS.VERIFIED, + }) + .cache('total_contributors', 1000) + .getRawOne(), + ]); + + return { + totalCollected: totalCollected.total_collected, + qfTotalCollected: qfTotalCollected.total_qf_collected, + totalContributors: totalContributors.total_contributors, + }; +}; + export default { getQAccDonationCap, validDonationAmountBasedOnKYCAndScore, getUserRemainedCapBasedOnGitcoinScore, + getQAccStat, }; diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index e5092380b..9efd5dec9 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -143,6 +143,7 @@ export const createProjectQuery = ` nftContractAddress chainId } + tributeClaimModuleAddress } } `; @@ -197,6 +198,7 @@ export const updateProjectQuery = ` orchestratorAddress projectAddress } + tributeClaimModuleAddress } } `; @@ -1001,6 +1003,19 @@ export const fetchProjectBySlugQuery = ` totalReactions totalDonations totalTraceDonations + abc { + tokenName + tokenTicker + issuanceTokenAddress + fundingManagerAddress + icon + orchestratorAddress + projectAddress + creatorAddress + nftContractAddress + chainId + } + tributeClaimModuleAddress } } `; @@ -1508,6 +1523,19 @@ export const projectByIdQuery = ` id walletAddress } + abc { + tokenName + tokenTicker + issuanceTokenAddress + fundingManagerAddress + icon + orchestratorAddress + projectAddress + creatorAddress + nftContractAddress + chainId + } + tributeClaimModuleAddress } } `; @@ -2186,3 +2214,13 @@ export const userCaps = ` } } `; + +export const qAccStat = ` + query { + qAccStat { + totalCollected + qfTotalCollected + contributorsCount + } + } +`;