diff --git a/.github/workflows/staging-pipeline.yml b/.github/workflows/staging-pipeline.yml index 913bf5e37..962b49b7d 100644 --- a/.github/workflows/staging-pipeline.yml +++ b/.github/workflows/staging-pipeline.yml @@ -100,6 +100,7 @@ jobs: OPTIMISTIC_SCAN_API_KEY: ${{ secrets.OPTIMISTIC_SCAN_API_KEY }} CELO_SCAN_API_KEY: ${{ secrets.CELO_SCAN_API_KEY }} CELO_ALFAJORES_SCAN_API_KEY: ${{ secrets.CELO_ALFAJORES_SCAN_API_KEY }} + DROP_DATABASE: ${{ secrets.DROP_DATABASE }} publish: needs: test diff --git a/config/example.env b/config/example.env index 703d8c38f..be6c31e9d 100644 --- a/config/example.env +++ b/config/example.env @@ -201,3 +201,11 @@ POWER_BALANCE_AGGREGATOR_ADAPTER=powerBalanceAggregator #POWER_BALANCE_AGGREGATOR_ADAPTER=mock NUMBER_OF_BALANCE_AGGREGATOR_BATCH=20 +# OPTIONAL - default: 60000 (1 minute +QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION=60000 + +# OPTIONAL - default: Every 10 minutes +PROJECT_CAMPAIGNS_CACHE_DURATION=600000 + +# OPTIONAL - default: *0 */5 * * * ( Every 5 minutes) +CACHE_PROJECT_CAMPAIGNS_CRONJOB_EXPRESSION=0 */5 * * * * diff --git a/config/test.env b/config/test.env index 7163d1218..8ec66bc77 100644 --- a/config/test.env +++ b/config/test.env @@ -171,3 +171,13 @@ OPTIMISM_NODE_HTTP_URL=https://optimism-mainnet.public.blastapi.io BALANCE_AGGREGATOR_BASE_URL=https://dev.serve.giveth.io/givpower-balance-aggregator POWER_BALANCE_AGGREGATOR_ADAPTER=mock NUMBER_OF_BALANCE_AGGREGATOR_BATCH=7 + + +# ! millisecond cache, if we increase cache in test ENV we might get some errors in tests +QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION=1 +# ! millisecond cache, if we increase cache in test ENV we might get some errors in tests +PROJECT_CAMPAIGNS_CACHE_DURATION=1 + + +# OPTIONAL - default: *0 */5 * * * ( Every 5 minutes) +CACHE_PROJECT_CAMPAIGNS_CRONJOB_EXPRESSION=0 */5 * * * * diff --git a/migration/1694295208252-AddEligibleNetworksToQfRoundEntity.ts b/migration/1694295208252-AddEligibleNetworksToQfRoundEntity.ts new file mode 100644 index 000000000..e7c2e85f4 --- /dev/null +++ b/migration/1694295208252-AddEligibleNetworksToQfRoundEntity.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddEligibleNetworksToQfRoundEntity1694295208252 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.qf_round + ADD COLUMN IF NOT EXISTS "eligibleNetworks" integer array DEFAULT ARRAY[]::integer[] + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('qf_round', 'eligibleNetworks'); + } +} diff --git a/migration/1694635872128-AddEligibleNetworksToPreviousQfRounds.ts b/migration/1694635872128-AddEligibleNetworksToPreviousQfRounds.ts new file mode 100644 index 000000000..d8d6df46f --- /dev/null +++ b/migration/1694635872128-AddEligibleNetworksToPreviousQfRounds.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config from '../src/config'; + +export class AddEligibleNetworksToPreviousQfRounds1694635872128 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const environment = config.get('ENVIRONMENT') as string; + + // Define the eligible network IDs based on the conditions + const eligibleNetworks = + environment !== 'production' + ? [1, 3, 5, 100, 137, 10, 420, 56, 42220, 44787] // Include testnets for staging + : [1, 137, 56, 42220, 100, 10]; // Exclude testnets for non-staging + + // Update the "qf_round" table with the new eligibleNetworks values + await queryRunner.query( + ` + UPDATE public.qf_round + SET "eligibleNetworks" = $1 + `, + [eligibleNetworks], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE public.qf_round + SET "eligibleNetworks" = '{}' + `); + } +} diff --git a/package-lock.json b/package-lock.json index dd3a6ad55..9ea20d785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "giveth-graphql-api", - "version": "1.16.1", + "version": "1.17.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "giveth-graphql-api", - "version": "1.16.1", + "version": "1.17.0", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -23513,7 +23513,7 @@ "integrity": "sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ==", "dev": true, "requires": { - "semver-regex": "3.1.4" + "semver-regex": "^3.1.2" } }, "find-yarn-workspace-root": { diff --git a/package.json b/package.json index 51fef1546..e52214ade 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "giveth-graphql-api", - "version": "1.16.1", + "version": "1.17.0", "description": "Backend GraphQL server for Giveth originally forked from Topia", "main": "./dist/index.js", "dependencies": { diff --git a/src/entities/ProjectEstimatedMatchingView.ts b/src/entities/ProjectEstimatedMatchingView.ts index b63ff7903..518e70ce9 100644 --- a/src/entities/ProjectEstimatedMatchingView.ts +++ b/src/entities/ProjectEstimatedMatchingView.ts @@ -1,6 +1,19 @@ -import { Entity, Column, Index, PrimaryColumn } from 'typeorm'; +import { Field, ObjectType } from 'type-graphql'; +import { + Entity, + Column, + Index, + PrimaryColumn, + BaseEntity, + ViewEntity, + ManyToOne, + RelationId, + ViewColumn, + JoinColumn, +} from 'typeorm'; +import { Project } from './project'; -@Entity({ name: 'project_estimated_matching_view' }) +@ViewEntity('project_estimated_matching_view', { synchronize: false }) @Index('project_estimated_matching_view_project_id_qfround_id', [ 'projectId', 'qfRoundId', @@ -13,28 +26,41 @@ import { Entity, Column, Index, PrimaryColumn } from 'typeorm'; @Index('project_estimated_matching_view_unique_donation_count', [ 'uniqueDonationCount', ]) -export class ProjectEstimatedMatchingView { - // Project ID associated with the donations +@ObjectType() +export class ProjectEstimatedMatchingView extends BaseEntity { + @Field(type => Project) + @ManyToOne(type => Project, project => project.projectEstimatedMatchingView) + @JoinColumn({ referencedColumnName: 'id' }) + project: Project; + + @Field() + @ViewColumn() @PrimaryColumn() projectId: number; // QF Round ID associated with the donations + @ViewColumn() + @Field() @PrimaryColumn() qfRoundId: number; // Sum of the square root of the value in USD of the donations + @ViewColumn() @Column('double precision') sqrtRootSum: number; // Count of unique donations per user per project per QF round + @ViewColumn() @Column('int') uniqueDonationCount: number; // Sum of the value in USD of the donations for active QF rounds where the donation status is verified + @ViewColumn() @Column('double precision') sumValueUsd: number; // Count of unique donors who have verified donations for each project + @ViewColumn() @Column('int') uniqueDonorsCount: number; } diff --git a/src/entities/campaign.ts b/src/entities/campaign.ts index fcd3d8c21..7e1860e10 100644 --- a/src/entities/campaign.ts +++ b/src/entities/campaign.ts @@ -35,9 +35,26 @@ export enum CampaignFilterField { } export enum CampaignType { + // https://github.com/Giveth/impact-graph/blob/staging/docs/campaignsInstruction.md + + // In these type of projects we pick some projects to show them in campaign, + // for instance for Turkey earthquake we pick some projects. + // so we just need to add slug of those projects in Related Projects Slugs and in + // what order we add them they will be shown in frontend ManuallySelected = 'ManuallySelected', + + // Sometimes in a campaign we just want to show projects in an specified order, + // for instance we can create a campaign like ** Check projects that received most likes** so for + // this campaign you set SortField as campaign type and then you can use one of below sorting fields SortField = 'SortField', + + // Sometimes we need to filter some projects in a campaign, + // for instance Let's verified projects that accept funds on Gnosis chain, + // for this we can Add verified and acceptFundOnGnosis filters FilterFields = 'FilterFields', + + // Some campaigns don't include any project in them and they are just some banner + // like Feeling $nice? campaign in below image WithoutProjects = 'WithoutProjects', } diff --git a/src/entities/entities.ts b/src/entities/entities.ts index ea5c39429..158f4df3f 100644 --- a/src/entities/entities.ts +++ b/src/entities/entities.ts @@ -41,6 +41,7 @@ import { ProjectInstantPowerView } from '../views/projectInstantPowerView'; import { QfRound } from './qfRound'; import { ReferredEvent } from './referredEvent'; import { QfRoundHistory } from './qfRoundHistory'; +import { ProjectEstimatedMatchingView } from './ProjectEstimatedMatchingView'; export const getEntities = (): DataSourceOptions['entities'] => { return [ @@ -81,6 +82,7 @@ export const getEntities = (): DataSourceOptions['entities'] => { LastSnapshotProjectPowerView, ProjectInstantPowerView, ProjectUserInstantPowerView, + ProjectEstimatedMatchingView, // historic snapshots PowerSnapshotHistory, diff --git a/src/entities/project.ts b/src/entities/project.ts index 5cb6bad43..a39f4714a 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -51,6 +51,8 @@ import { getQfRoundTotalProjectsDonationsSum, } from '../repositories/qfRoundRepository'; import { EstimatedMatching } from '../types/qfTypes'; +import { Campaign } from './campaign'; +import { ProjectEstimatedMatchingView } from './ProjectEstimatedMatchingView'; // tslint:disable-next-line:no-var-requires const moment = require('moment'); @@ -75,6 +77,7 @@ export enum SortingField { QualityScore = 'QualityScore', GIVPower = 'GIVPower', InstantBoosting = 'InstantBoosting', + ActiveQfRoundRaisedFunds = 'ActiveQfRoundRaisedFunds', } export enum FilterField { @@ -101,7 +104,6 @@ export enum OrderField { QualityScore = 'qualityScore', Verified = 'verified', Reactions = 'totalReactions', - Traceable = 'traceCampaignId', Donations = 'totalDonations', TraceDonations = 'totalTraceDonations', AcceptGiv = 'givingBlocksId', @@ -341,6 +343,13 @@ export class Project extends BaseEntity { @OneToMany(type => SocialProfile, socialProfile => socialProfile.project) socialProfiles?: SocialProfile[]; + @Field(type => [ProjectEstimatedMatchingView], { nullable: true }) + @OneToMany( + type => ProjectEstimatedMatchingView, + projectEstimatedMatchingView => projectEstimatedMatchingView.project, + ) + projectEstimatedMatchingView?: ProjectEstimatedMatchingView[]; + @Field(type => Float) @Column({ type: 'real' }) totalDonations: number; @@ -390,6 +399,9 @@ export class Project extends BaseEntity { @Field({ nullable: true }) reaction?: Reaction; + @Field(type => [Campaign], { nullable: true }) + campaigns: Campaign[]; + // only projects with status active can be listed automatically static pendingReviewSince(maximumDaysForListing: Number) { const maxDaysForListing = moment() diff --git a/src/entities/qfRound.ts b/src/entities/qfRound.ts index bfe637a14..81d503643 100644 --- a/src/entities/qfRound.ts +++ b/src/entities/qfRound.ts @@ -1,4 +1,4 @@ -import { Field, ID, ObjectType } from 'type-graphql'; +import { Field, ID, ObjectType, Int } from 'type-graphql'; import { PrimaryGeneratedColumn, Column, @@ -36,6 +36,10 @@ export class QfRound extends BaseEntity { @Column() minimumPassportScore: number; + @Field(type => [Int], { nullable: true }) // Define the new field as an array of integers + @Column('integer', { array: true, default: [] }) + eligibleNetworks: number[]; + @Field(type => Date) @Column() beginDate: Date; @@ -52,4 +56,12 @@ export class QfRound extends BaseEntity { @ManyToMany(type => Project, project => project.qfRounds) projects: Project[]; + + // only projects with status active can be listed automatically + isEligibleNetwork(donationNetworkId: number): Boolean { + // when not specified, all are valid + if (this.eligibleNetworks.length === 0) return true; + + return this.eligibleNetworks.includes(donationNetworkId); + } } diff --git a/src/repositories/donationRepository.test.ts b/src/repositories/donationRepository.test.ts index 89cfba913..ca9136615 100644 --- a/src/repositories/donationRepository.test.ts +++ b/src/repositories/donationRepository.test.ts @@ -20,6 +20,7 @@ import { findDonationsByTransactionId, findStableCoinDonationsWithoutPrice, getPendingDonationsIds, + isVerifiedDonationExistsInQfRound, sumDonationValueUsd, sumDonationValueUsdForQfRound, } from './donationRepository'; @@ -64,6 +65,10 @@ describe( describe('countUniqueDonors() test cases', countUniqueDonorsTestCases); describe('sumDonationValueUsd() test cases', sumDonationValueUsdTestCases); describe('estimatedMatching() test cases', estimatedMatchingTestCases); +describe( + 'isVerifiedDonationExistsInQfRound() test cases', + isVerifiedDonationExistsInQfRoundTestCases, +); function fillQfRoundDonationsUserScoresTestCases() { let qfRound: QfRound; @@ -1262,3 +1267,191 @@ function sumDonationValueUsdTestCases() { assert.equal(donationSum, valueUsd1 + valueUsd2 + valueUsd3); }); } + +function isVerifiedDonationExistsInQfRoundTestCases() { + it('should return true when there is a verified donation', async () => { + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const donor = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + status: 'verified', + qfRoundId: qfRound.id, + }, + donor.id, + project.id, + ); + const doesExist = await isVerifiedDonationExistsInQfRound({ + projectId: project.id, + qfRoundId: qfRound.id, + userId: donor.id, + }); + assert.isTrue(doesExist); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when there is a non-verified donation', async () => { + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const donor = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + status: 'pending', + qfRoundId: qfRound.id, + }, + donor.id, + project.id, + ); + const doesExist = await isVerifiedDonationExistsInQfRound({ + projectId: project.id, + qfRoundId: qfRound.id, + userId: donor.id, + }); + assert.isFalse(doesExist); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when sending invalid userId', async () => { + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const donor = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + status: 'verified', + qfRoundId: qfRound.id, + }, + donor.id, + project.id, + ); + const doesExist = await isVerifiedDonationExistsInQfRound({ + projectId: project.id, + qfRoundId: qfRound.id, + userId: 999999, + }); + assert.isFalse(doesExist); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when sending invalid projectId', async () => { + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const donor = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + status: 'verified', + qfRoundId: qfRound.id, + }, + donor.id, + project.id, + ); + const doesExist = await isVerifiedDonationExistsInQfRound({ + projectId: 9999900, + qfRoundId: qfRound.id, + userId: donor.id, + }); + assert.isFalse(doesExist); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when sending invalid qfRoundId', async () => { + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const donor = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + status: 'verified', + qfRoundId: qfRound.id, + }, + donor.id, + project.id, + ); + const doesExist = await isVerifiedDonationExistsInQfRound({ + projectId: project.id, + qfRoundId: 9999999, + userId: donor.id, + }); + assert.isFalse(doesExist); + + qfRound.isActive = false; + await qfRound.save(); + }); +} diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index 9bf2825fc..90329f1a0 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -7,6 +7,7 @@ import moment from 'moment'; import { ProjectEstimatedMatchingView } from '../entities/ProjectEstimatedMatchingView'; import { AppDataSource } from '../orm'; import { getProjectDonationsSqrtRootSum } from './qfRoundRepository'; +import { logger } from '../utils/logger'; export const fillQfRoundDonationsUserScores = async (): Promise => { await Donation.query(` @@ -379,3 +380,33 @@ export async function sumDonationValueUsd(projectId: number): Promise { return result[0]?.sumVerifiedDonations || 0; } + +export async function isVerifiedDonationExistsInQfRound(params: { + qfRoundId: number; + projectId: number; + userId: number; +}): Promise { + try { + const result = await Donation.query( + ` + SELECT EXISTS ( + SELECT 1 + FROM donation + WHERE + status = 'verified' AND + "qfRoundId" = $1 AND + "projectId" = $2 AND + "userId" = $3 + ) AS exists; + `, + [params.qfRoundId, params.projectId, params.userId], + ); + return result?.[0]?.exists || false; + } catch (err) { + logger.error( + 'Error executing the query in isVerifiedDonationExists() function', + err, + ); + return false; + } +} diff --git a/src/repositories/projectRepository.test.ts b/src/repositories/projectRepository.test.ts index 50cce6ef4..b4a20ce89 100644 --- a/src/repositories/projectRepository.test.ts +++ b/src/repositories/projectRepository.test.ts @@ -1,6 +1,7 @@ import { findProjectById, findProjectBySlug, + findProjectBySlugWithoutAnyJoin, findProjectByWalletAddress, findProjectsByIdArray, findProjectsBySlugArray, @@ -56,6 +57,20 @@ describe( updateDescriptionSummaryTestCases, ); +describe('verifyProject test cases', verifyProjectTestCases); +describe('verifyMultipleProjects test cases', verifyMultipleProjectsTestCases); +describe('findProjectById test cases', findProjectByIdTestCases); +describe('findProjectsByIdArray test cases', findProjectsByIdArrayTestCases); +describe('findProjectBySlug test cases', findProjectBySlugTestCases); +describe( + 'findProjectBySlugWithoutAnyJoin test cases', + findProjectBySlugWithoutAnyJoinTestCases, +); +describe( + 'findProjectsBySlugArray test cases', + findProjectsBySlugArrayTestCases, +); + function projectsWithoutUpdateAfterTimeFrameTestCases() { it('should return projects created a long time ago', async () => { const superExpiredProject = await saveProjectDirectlyToDb({ @@ -96,22 +111,13 @@ function projectsWithoutUpdateAfterTimeFrameTestCases() { }); } -describe('verifyProject test cases', verifyProjectTestCases); -describe('verifyMultipleProjects test cases', verifyMultipleProjectsTestCases); -describe('findProjectById test cases', findProjectByIdTestCases); -describe('findProjectsByIdArray test cases', findProjectsByIdArrayTestCases); -describe('findProjectBySlug test cases', findProjectBySlugTestCases); -describe( - 'findProjectsBySlugArray test cases', - findProjectsBySlugArrayTestCases, -); - function findProjectBySlugTestCases() { - it('Should find project by id', async () => { + it('Should find project by slug', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); const foundProject = await findProjectBySlug(project.slug as string); assert.isOk(foundProject); assert.equal(foundProject?.id, project.id); + assert.isOk(foundProject?.adminUser); }); it('should not find project when project doesnt exists', async () => { @@ -120,6 +126,25 @@ function findProjectBySlugTestCases() { }); } +function findProjectBySlugWithoutAnyJoinTestCases() { + it('Should find project by slug', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const foundProject = await findProjectBySlugWithoutAnyJoin( + project.slug as string, + ); + assert.isOk(foundProject); + assert.equal(foundProject?.id, project.id); + assert.isNotOk(foundProject?.adminUser); + }); + + it('should not find project when project doesnt exists', async () => { + const foundProject = await findProjectBySlugWithoutAnyJoin( + new Date().toString(), + ); + assert.isNull(foundProject); + }); +} + function findProjectsBySlugArrayTestCases() { it('Should find project multi projects by slug', async () => { const project1 = await saveProjectDirectlyToDb(createProjectData()); diff --git a/src/repositories/projectRepository.ts b/src/repositories/projectRepository.ts index 72f7003b4..db9b2fa42 100644 --- a/src/repositories/projectRepository.ts +++ b/src/repositories/projectRepository.ts @@ -9,15 +9,10 @@ import { } from '../entities/project'; import { ProjectVerificationForm } from '../entities/projectVerificationForm'; import { ProjectAddress } from '../entities/projectAddress'; -import { - errorMessages, - i18n, - translationErrorMessagesKeys, -} from '../utils/errorMessages'; +import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages'; import { User, publicSelectionFields } from '../entities/user'; import { ResourcesTotalPerMonthAndYear } from '../resolvers/donationResolver'; import { OrderDirection, ProjectResolver } from '../resolvers/projectResolver'; - export const findProjectById = (projectId: number): Promise => { // return Project.findOne({ id: projectId }); @@ -59,6 +54,7 @@ export type FilterProjectQueryInputParams = { slugArray?: string[]; sortingBy?: SortingField; qfRoundId?: number; + activeQfRoundId?: number; }; export const filterProjectsQuery = (params: FilterProjectQueryInputParams) => { const { @@ -71,6 +67,7 @@ export const filterProjectsQuery = (params: FilterProjectQueryInputParams) => { sortingBy, slugArray, qfRoundId, + activeQfRoundId, } = params; let query = Project.createQueryBuilder('project') @@ -100,12 +97,12 @@ export const filterProjectsQuery = (params: FilterProjectQueryInputParams) => { `project.statusId = ${ProjStatus.active} AND project.reviewStatus = :reviewStatus`, { reviewStatus: ReviewStatus.Listed }, ); - if (qfRoundId) { + if (qfRoundId || activeQfRoundId) { query.innerJoinAndSelect( 'project.qfRounds', 'qf_rounds', 'qf_rounds.id = :qfRoundId', - { qfRoundId }, + { qfRoundId: qfRoundId ? qfRoundId : activeQfRoundId }, ); } if (!sortingBy || sortingBy === SortingField.InstantBoosting) { @@ -168,6 +165,27 @@ export const filterProjectsQuery = (params: FilterProjectQueryInputParams) => { 'NULLS LAST', ); break; + case SortingField.ActiveQfRoundRaisedFunds: + if (activeQfRoundId) { + query + .leftJoin( + 'project.projectEstimatedMatchingView', + 'projectEstimatedMatchingView', + 'projectEstimatedMatchingView.qfRoundId = :qfRoundId', + { qfRoundId: activeQfRoundId }, + ) + .addSelect([ + 'projectEstimatedMatchingView.sumValueUsd', + 'projectEstimatedMatchingView.qfRoundId', + ]) + .orderBy( + 'projectEstimatedMatchingView.sumValueUsd', + OrderDirection.DESC, + 'NULLS LAST', + ) + .addOrderBy(`project.verified`, OrderDirection.DESC); + } + break; default: query .orderBy('projectInstantPower.totalPower', OrderDirection.DESC) @@ -211,6 +229,17 @@ export const findProjectBySlug = (slug: string): Promise => { ); }; +export const findProjectBySlugWithoutAnyJoin = ( + slug: string, +): Promise => { + // check current slug and previous slugs + return Project.createQueryBuilder('project') + .where(`:slug = ANY(project."slugHistory") or project.slug = :slug`, { + slug, + }) + .getOne(); +}; + export const verifyMultipleProjects = async (params: { verified: boolean; projectsIds: string[] | number[]; diff --git a/src/repositories/qfRoundRepository.ts b/src/repositories/qfRoundRepository.ts index f4474c6d5..0f66f3c1f 100644 --- a/src/repositories/qfRoundRepository.ts +++ b/src/repositories/qfRoundRepository.ts @@ -1,5 +1,10 @@ import { QfRound } from '../entities/qfRound'; import { AppDataSource } from '../orm'; +import { logger } from '../utils/logger'; + +const qfRoundEstimatedMatchingParamsCacheDuration = Number( + process.env.QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION || 60000, +); export const findAllQfRounds = async (): Promise => { return QfRound.createQueryBuilder('qf_round') @@ -47,18 +52,25 @@ export async function getProjectDonationsSqrtRootSum( projectId: number, qfRoundId: number, ): Promise<{ sqrtRootSum: number; uniqueDonorsCount: number }> { - const result = await AppDataSource.getDataSource().query( - ` - SELECT "sqrtRootSum", "uniqueDonorsCount" - FROM project_estimated_matching_view - WHERE "projectId" = $1 AND "qfRoundId" = $2; - `, - [projectId, qfRoundId], - ); + const result = await AppDataSource.getDataSource() + .createQueryBuilder() + .select('"sqrtRootSum"') + .addSelect('"uniqueDonorsCount"') + .from('project_estimated_matching_view', 'project_estimated_matching_view') + .where('"projectId" = :projectId AND "qfRoundId" = :qfRoundId', { + projectId, + qfRoundId, + }) + // Add cache here + .cache( + 'projectDonationsSqrtRootSum_' + projectId + '_' + qfRoundId, + qfRoundEstimatedMatchingParamsCacheDuration, + ) + .getRawOne(); return { - sqrtRootSum: result[0] ? result[0].sqrtRootSum : 0, - uniqueDonorsCount: result[0] ? Number(result[0].uniqueDonorsCount) : 0, + sqrtRootSum: result ? result.sqrtRootSum : 0, + uniqueDonorsCount: result ? Number(result.uniqueDonorsCount) : 0, }; } @@ -68,18 +80,21 @@ export const getQfRoundTotalProjectsDonationsSum = async ( sum: number; contributorsCount: number; }> => { - const query = ` - SELECT - SUM("sqrtRootSumSquared") as "sum", - SUM("donorsCount") as "contributorsCount" - FROM project_estimated_matching_view - WHERE "qfRoundId" = $1; - `; - - const result = await AppDataSource.getDataSource().query(query, [qfRoundId]); - - const sum = result[0]?.sum || 0; - const contributorsCount = parseInt(result[0]?.contributorsCount, 10) || 0; + const result = await AppDataSource.getDataSource() + .createQueryBuilder() + .select(`SUM("sqrtRootSumSquared")`, 'sum') + .addSelect(`SUM("donorsCount")`, 'contributorsCount') + .from('project_estimated_matching_view', 'project_estimated_matching_view') + .where('"qfRoundId" = :qfRoundId', { qfRoundId }) + // Add cache here + .cache( + 'qfRoundTotalProjectsDonationsSum_' + qfRoundId, + qfRoundEstimatedMatchingParamsCacheDuration, + ) + .getRawOne(); + + const sum = result?.sum || 0; + const contributorsCount = parseInt(result?.contributorsCount, 10) || 0; return { sum, diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index 68666dd42..67179b555 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -16,7 +16,6 @@ import { import axios from 'axios'; import { errorMessages } from '../utils/errorMessages'; import { Donation, DONATION_STATUS } from '../entities/donation'; -import sinon from 'sinon'; import { fetchDonationsByUserIdQuery, fetchDonationsByDonorQuery, @@ -31,6 +30,7 @@ import { fetchTotalDonationsPerCategoryPerDate, fetchRecentDonations, fetchTotalDonationsNumberPerDateRange, + doesDonatedToProjectInQfRoundQuery, } from '../../test/graphqlQueries'; import { NETWORK_IDS } from '../provider'; import { User } from '../entities/user'; @@ -69,6 +69,10 @@ describe('donationsToWallets() test cases', donationsToWalletsTestCases); describe('donationsFromWallets() test cases', donationsFromWalletsTestCases); describe('totalDonationsUsdAmount() test cases', donationsUsdAmountTestCases); describe('totalDonorsCountPerDate() test cases', donorsCountPerDateTestCases); +describe( + 'doesDonatedToProjectInQfRound() test cases', + doesDonatedToProjectInQfRoundTestCases, +); describe( 'totalDonationsNumberPerDate() test cases', totalDonationsNumberPerDateTestCases, @@ -77,7 +81,7 @@ describe( 'totalDonationsPerCategoryPerDate() test cases', totalDonationsPerCategoryPerDateTestCases, ); -describe('resetDonations() test cases', recentDonationsTestCases); +describe('recentDonations() test cases', recentDonationsTestCases); // // describe('tokens() test cases', tokensTestCases); @@ -209,6 +213,204 @@ function donorsCountPerDateTestCases() { }); } +function doesDonatedToProjectInQfRoundTestCases() { + it('should return true when there is verified donation', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const walletAddress = generateRandomEtheriumAddress(); + const user = await saveUserDirectlyToDb(walletAddress); + // should count as 1 as its the same user + await saveDonationDirectlyToDb( + createDonationData({ + status: DONATION_STATUS.VERIFIED, + createdAt: moment().add(50, 'days').toDate(), + valueUsd: 20, + qfRoundId: qfRound.id, + }), + user.id, + project.id, + ); + + const result = await axios.post(graphqlUrl, { + query: doesDonatedToProjectInQfRoundQuery, + variables: { + projectId: project.id, + userId: user.id, + qfRoundId: qfRound.id, + }, + }); + assert.isTrue(result.data.data.doesDonatedToProjectInQfRound); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when donation is non-verified', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const walletAddress = generateRandomEtheriumAddress(); + const user = await saveUserDirectlyToDb(walletAddress); + // should count as 1 as its the same user + await saveDonationDirectlyToDb( + createDonationData({ + status: DONATION_STATUS.PENDING, + createdAt: moment().add(50, 'days').toDate(), + valueUsd: 20, + qfRoundId: qfRound.id, + }), + user.id, + project.id, + ); + + const result = await axios.post(graphqlUrl, { + query: doesDonatedToProjectInQfRoundQuery, + variables: { + projectId: project.id, + userId: user.id, + qfRoundId: qfRound.id, + }, + }); + assert.isFalse(result.data.data.doesDonatedToProjectInQfRound); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when donation projectId is invalid', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const walletAddress = generateRandomEtheriumAddress(); + const user = await saveUserDirectlyToDb(walletAddress); + // should count as 1 as its the same user + await saveDonationDirectlyToDb( + createDonationData({ + status: DONATION_STATUS.PENDING, + createdAt: moment().add(50, 'days').toDate(), + valueUsd: 20, + qfRoundId: qfRound.id, + }), + user.id, + project.id, + ); + + const result = await axios.post(graphqlUrl, { + query: doesDonatedToProjectInQfRoundQuery, + variables: { + projectId: 99999, + userId: user.id, + qfRoundId: qfRound.id, + }, + }); + assert.isFalse(result.data.data.doesDonatedToProjectInQfRound); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when donation qfRoundId is invalid', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const walletAddress = generateRandomEtheriumAddress(); + const user = await saveUserDirectlyToDb(walletAddress); + // should count as 1 as its the same user + await saveDonationDirectlyToDb( + createDonationData({ + status: DONATION_STATUS.PENDING, + createdAt: moment().add(50, 'days').toDate(), + valueUsd: 20, + qfRoundId: qfRound.id, + }), + user.id, + project.id, + ); + + const result = await axios.post(graphqlUrl, { + query: doesDonatedToProjectInQfRoundQuery, + variables: { + projectId: project.id, + userId: user.id, + qfRoundId: 99999, + }, + }); + assert.isFalse(result.data.data.doesDonatedToProjectInQfRound); + + qfRound.isActive = false; + await qfRound.save(); + }); + it('should return false when donation userId is invalid', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + allocatedFund: 100, + minimumPassportScore: 12, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const walletAddress = generateRandomEtheriumAddress(); + const user = await saveUserDirectlyToDb(walletAddress); + // should count as 1 as its the same user + await saveDonationDirectlyToDb( + createDonationData({ + status: DONATION_STATUS.PENDING, + createdAt: moment().add(50, 'days').toDate(), + valueUsd: 20, + qfRoundId: qfRound.id, + }), + user.id, + project.id, + ); + + const result = await axios.post(graphqlUrl, { + query: doesDonatedToProjectInQfRoundQuery, + variables: { + projectId: project.id, + userId: 99999, + qfRoundId: qfRound.id, + }, + }); + assert.isFalse(result.data.data.doesDonatedToProjectInQfRound); + + qfRound.isActive = false; + await qfRound.save(); + }); +} + function donationsUsdAmountTestCases() { it('should return total usd amount for donations made in a time range', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); @@ -608,6 +810,102 @@ function createDonationTestCases() { qfRound.isActive = false; await qfRound.save(); }); + + it('should create a donation in an active qfRound when qfround has network eligiblity on XDAI', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const qfRound = await QfRound.create({ + isActive: true, + name: new Date().toString(), + minimumPassportScore: 8, + allocatedFund: 100, + eligibleNetworks: [100], // accepts ONLY xdai to mark as part of QFround + beginDate: new Date(), + endDate: moment().add(2, 'day'), + }).save(); + project.qfRounds = [qfRound]; + await project.save(); + const referrerId = generateRandomString(); + const referrerWalletAddress = + await getChainvineAdapter().getWalletAddressFromReferrer(referrerId); + + const user = await User.create({ + walletAddress: generateRandomEtheriumAddress(), + loginType: 'wallet', + firstName: 'first name', + }).save(); + + const user2 = await User.create({ + walletAddress: referrerWalletAddress, + loginType: 'wallet', + firstName: 'first name', + }).save(); + + const referredEvent = await firstOrCreateReferredEventByUserId(user.id); + referredEvent.startTime = new Date(); + await referredEvent.save(); + + // should save Xdai + const accessToken = await generateTestAccessToken(user.id); + const saveDonationResponseXdai = await axios.post( + graphqlUrl, + { + query: createDonationMutation, + variables: { + projectId: project.id, + transactionNetworkId: NETWORK_IDS.XDAI, + transactionId: generateRandomTxHash(), + nonce: 1, + amount: 10, + token: 'GIV', + referrerId, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.isOk(saveDonationResponseXdai.data.data.createDonation); + const donation = await Donation.findOne({ + where: { + id: saveDonationResponseXdai.data.data.createDonation, + }, + }); + + assert.equal(donation?.qfRound?.id as number, qfRound.id); + + // should ignore non xdai donations because its not an eligible network + const saveDonationResponseNotXdai = await axios.post( + graphqlUrl, + { + query: createDonationMutation, + variables: { + projectId: project.id, + transactionNetworkId: NETWORK_IDS.CELO, + transactionId: generateRandomTxHash(), + nonce: 1, + amount: 10, + token: 'GIV', + referrerId, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.isOk(saveDonationResponseNotXdai.data.data.createDonation); + const donationNotFromQF = await Donation.findOne({ + where: { + id: saveDonationResponseNotXdai.data.data.createDonation, + }, + }); + assert.isNull(donationNotFromQF?.qfRound); + qfRound.isActive = false; + await qfRound.save(); + }); it('should create a donation in an active qfRound, when project is not listed', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); const qfRound = await QfRound.create({ diff --git a/src/resolvers/donationResolver.ts b/src/resolvers/donationResolver.ts index 3c03ed5ee..6d95fc3cd 100644 --- a/src/resolvers/donationResolver.ts +++ b/src/resolvers/donationResolver.ts @@ -45,8 +45,6 @@ import { setUserAsReferrer, } from '../repositories/userRepository'; import { - countUniqueDonors, - countUniqueDonorsForRound, donationsNumberPerDateRange, donationsTotalAmountPerDateRange, donationsTotalAmountPerDateRangeByMonth, @@ -55,8 +53,7 @@ import { donorsCountPerDateByMonthAndYear, findDonationById, getRecentDonations, - sumDonationValueUsd, - sumDonationValueUsdForQfRound, + isVerifiedDonationExistsInQfRound, } from '../repositories/donationRepository'; import { sleep } from '../utils/utils'; import { findProjectRecipientAddressByNetworkId } from '../repositories/projectAddressRepository'; @@ -727,7 +724,10 @@ export class DonationResolver { const activeQfRoundForProject = await relatedActiveQfRoundForProject( projectId, ); - if (activeQfRoundForProject) { + if ( + activeQfRoundForProject && + activeQfRoundForProject.isEligibleNetwork(Number(transactionNetworkId)) + ) { donation.qfRound = activeQfRoundForProject; } await donation.save(); @@ -839,4 +839,17 @@ export class DonationResolver { throw e; } } + + @Query(() => Boolean) + async doesDonatedToProjectInQfRound( + @Arg('projectId', _ => Int) projectId: number, + @Arg('qfRoundId', _ => Int) qfRoundId: number, + @Arg('userId', _ => Int) userId: number, + ): Promise { + return isVerifiedDonationExistsInQfRound({ + projectId, + qfRoundId, + userId, + }); + } } diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 181c81150..5a3ddb2a7 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -29,7 +29,7 @@ import { fetchLikedProjectsQuery, fetchMultiFilterAllProjectsQuery, fetchNewProjectsPerDate, - fetchProjectsBySlugQuery, + fetchProjectBySlugQuery, fetchProjectUpdatesQuery, fetchSimilarProjectsBySlugQuery, getProjectsAcceptTokensQuery, @@ -90,7 +90,12 @@ import { refreshUserProjectPowerView } from '../repositories/userProjectPowerVie import { AppDataSource } from '../orm'; // We are using cache so redis needs to be cleared for tests with same filters import { redis } from '../redis'; -import { Campaign, CampaignType } from '../entities/campaign'; +import { + Campaign, + CampaignFilterField, + CampaignSortingField, + CampaignType, +} from '../entities/campaign'; import { generateRandomString, getHtmlTextSummary } from '../utils/utils'; import { FeaturedUpdate } from '../entities/featuredUpdate'; import { @@ -109,6 +114,7 @@ import { } from '../services/projectViewsService'; import { addOrUpdatePowerSnapshotBalances } from '../repositories/powerBalanceSnapshotRepository'; import { findPowerSnapshots } from '../repositories/powerSnapshotRepository'; +import { cacheProjectCampaigns } from '../services/campaignService'; const ARGUMENT_VALIDATION_ERROR_MESSAGE = new ArgumentValidationError([ { property: '' }, @@ -597,6 +603,74 @@ function allProjectsTestCases() { ); }); + // it('should return projects, sort by project raised funds in the active QF round DESC', async () => { + // const donor = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + // const project1 = await saveProjectDirectlyToDb({ + // ...createProjectData(), + // title: String(new Date().getTime()), + // slug: String(new Date().getTime()), + // }); + // const project2 = await saveProjectDirectlyToDb({ + // ...createProjectData(), + // title: String(new Date().getTime()), + // slug: String(new Date().getTime()), + // }); + + // const qfRound = await QfRound.create({ + // isActive: true, + // name: 'test filter by qfRoundId', + // minimumPassportScore: 10, + // allocatedFund: 100, + // beginDate: new Date(), + // endDate: moment().add(1, 'day').toDate(), + // }).save(); + // project1.qfRounds = [qfRound]; + // await project1.save(); + // project2.qfRounds = [qfRound]; + // await project2.save(); + + // const donation1 = await saveDonationDirectlyToDb( + // { + // ...createDonationData(), + // status: 'verified', + // qfRoundId: qfRound.id, + // valueUsd: 2, + // }, + // donor.id, + // project1.id, + // ); + + // const donation2 = await saveDonationDirectlyToDb( + // { + // ...createDonationData(), + // status: 'verified', + // qfRoundId: qfRound.id, + // valueUsd: 20, + // }, + // donor.id, + // project2.id, + // ); + + // await refreshProjectEstimatedMatchingView(); + // await refreshProjectDonationSummaryView(); + + // const result = await axios.post(graphqlUrl, { + // query: fetchMultiFilterAllProjectsQuery, + // variables: { + // sortingBy: SortingField.ActiveQfRoundRaisedFunds, + // limit: 10, + // }, + // }); + + // assert.equal(result.data.data.allProjects.projects.length, 2); + // assert.equal(result.data.data.allProjects.projects[0].id, project2.id); + // result.data.data.allProjects.projects.forEach(project => { + // assert.equal(project.qfRounds[0].id, qfRound.id); + // }); + // qfRound.isActive = false; + // await qfRound.save(); + // }); + it('should return projects, sort by project instant power DESC', async () => { await PowerBoosting.clear(); await InstantPowerBalance.clear(); @@ -5319,7 +5393,7 @@ function projectBySlugTestCases() { const result = await axios.post( graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, connectedWalletUserId: user!.id, @@ -5362,7 +5436,7 @@ function projectBySlugTestCases() { }).save(); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, connectedWalletUserId: user!.id, @@ -5385,7 +5459,7 @@ function projectBySlugTestCases() { }); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, }, @@ -5443,7 +5517,7 @@ function projectBySlugTestCases() { await refreshProjectPowerView(); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, }, @@ -5456,6 +5530,221 @@ function projectBySlugTestCases() { assert.isTrue(project.projectPower.totalPower > 0); }); + it('should return projects including active ManuallySelected campaigns', async () => { + const projectWithCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const projectWithoutCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + + const campaign = await Campaign.create({ + isActive: true, + type: CampaignType.ManuallySelected, + slug: generateRandomString(), + title: 'title1', + description: 'description1', + photo: 'https://google.com', + relatedProjectsSlugs: [projectWithCampaign.slug as string], + order: 1, + }).save(); + const result = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + slug: projectWithCampaign.slug, + }, + }); + + const project = result.data.data.projectBySlug; + assert.equal(Number(project.id), projectWithCampaign.id); + + assert.exists(project.campaigns); + assert.isNotEmpty(project.campaigns); + + const projectWithoutCampaignResult = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + slug: projectWithoutCampaign.slug, + }, + }); + + const project2 = projectWithoutCampaignResult.data.data.projectBySlug; + assert.equal(Number(project2.id), projectWithoutCampaign.id); + + assert.isEmpty(project2.campaigns); + + await campaign.remove(); + }); + it('should return projects including active SortField campaigns', async () => { + const projectWithCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const campaign = await Campaign.create({ + isActive: true, + type: CampaignType.SortField, + sortingField: CampaignSortingField.Newest, + slug: generateRandomString(), + title: 'title1', + description: 'description1', + photo: 'https://google.com', + order: 1, + }).save(); + await cacheProjectCampaigns(); + const result = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + slug: projectWithCampaign.slug, + }, + }); + + const project = result.data.data.projectBySlug; + assert.equal(Number(project.id), projectWithCampaign.id); + + assert.exists(project.campaigns); + assert.isNotEmpty(project.campaigns); + assert.equal(project.campaigns[0].id, campaign.id); + + const projectWithoutCampaignResult = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + // and old project that I'm sure it would not be in the Newest campaign + slug: SEED_DATA.FIRST_PROJECT.slug, + }, + }); + + const project2 = projectWithoutCampaignResult.data.data.projectBySlug; + assert.equal(Number(project2.id), SEED_DATA.FIRST_PROJECT.id); + + assert.isEmpty(project2.campaigns); + + await campaign.remove(); + }); + + it('should return projects including active FilterField campaigns (acceptOnGnosis)', async () => { + // In this filter the default sorting for projects is givPower so I need to create a project with power + // to be sure that it will be in the campaign + await PowerBoosting.clear(); + await InstantPowerBalance.clear(); + + const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + const projectWithCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + networkId: NETWORK_IDS.XDAI, + }); + + const projectWithoutCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + networkId: NETWORK_IDS.POLYGON, + }); + + await Promise.all( + [[user1, projectWithCampaign, 10]].map(item => { + const [user, project, percentage] = item as [User, Project, number]; + return insertSinglePowerBoosting({ + user, + project, + percentage, + }); + }), + ); + + await saveOrUpdateInstantPowerBalances([ + { + userId: user1.id, + balance: 10000, + balanceAggregatorUpdatedAt: new Date(1_000_000), + }, + ]); + + await updateInstantBoosting(); + + const campaign = await Campaign.create({ + isActive: true, + type: CampaignType.FilterFields, + filterFields: [CampaignFilterField.acceptFundOnGnosis], + slug: generateRandomString(), + title: 'title1', + description: 'description1', + photo: 'https://google.com', + order: 1, + }).save(); + await cacheProjectCampaigns(); + const result = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + slug: projectWithCampaign.slug, + }, + }); + + const fetchedProject = result.data.data.projectBySlug; + assert.equal(Number(fetchedProject.id), projectWithCampaign.id); + + assert.exists(fetchedProject.campaigns); + assert.isNotEmpty(fetchedProject.campaigns); + assert.equal(fetchedProject.campaigns[0].id, campaign.id); + + const projectWithoutCampaignResult = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + slug: projectWithoutCampaign.slug, + }, + }); + + const project2 = projectWithoutCampaignResult.data.data.projectBySlug; + assert.equal(Number(project2.id), projectWithoutCampaign.id); + + assert.isEmpty(project2.campaigns); + + await campaign.remove(); + }); + + it('should return projects including active campaigns, even when sent slug is in the slugHistory of project', async () => { + const projectWithCampaign = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const previousSlug = `${String(new Date().getTime())}-previous`; + projectWithCampaign.slugHistory = [previousSlug]; + await projectWithCampaign.save(); + + const campaign = await Campaign.create({ + isActive: true, + type: CampaignType.ManuallySelected, + slug: generateRandomString(), + title: 'title1', + description: 'description1', + photo: 'https://google.com', + relatedProjectsSlugs: [previousSlug], + order: 1, + }).save(); + const result = await axios.post(graphqlUrl, { + query: fetchProjectBySlugQuery, + variables: { + slug: previousSlug, + }, + }); + + const project = result.data.data.projectBySlug; + assert.equal(Number(project.id), projectWithCampaign.id); + + assert.exists(project.campaigns); + assert.isNotEmpty(project.campaigns); + + await campaign.remove(); + }); + it('should return projects including project future power rank', async () => { await AppDataSource.getDataSource().query( 'truncate power_snapshot cascade', @@ -5537,7 +5826,7 @@ function projectBySlugTestCases() { await refreshProjectFuturePowerView(); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, }, @@ -5632,7 +5921,7 @@ function projectBySlugTestCases() { await refreshProjectFuturePowerView(false); let result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, }, @@ -5647,7 +5936,7 @@ function projectBySlugTestCases() { await refreshProjectFuturePowerView(true); result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, }, @@ -5666,7 +5955,7 @@ function projectBySlugTestCases() { }); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: draftedProject.slug, }, @@ -5690,7 +5979,7 @@ function projectBySlugTestCases() { const result = await axios.post( graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: draftedProject.slug, connectedWalletUserId: SEED_DATA.FIRST_USER.id, @@ -5721,7 +6010,7 @@ function projectBySlugTestCases() { const result = await axios.post( graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: draftedProject.slug, }, @@ -5749,7 +6038,7 @@ function projectBySlugTestCases() { }); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project.slug, }, @@ -5773,7 +6062,7 @@ function projectBySlugTestCases() { const result = await axios.post( graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project.slug, connectedWalletUserId: SEED_DATA.FIRST_USER.id, @@ -5804,7 +6093,7 @@ function projectBySlugTestCases() { const result = await axios.post( graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: cancelledProject.slug, }, @@ -5862,7 +6151,7 @@ function projectBySlugTestCases() { await updateInstantBoosting(); const result = await axios.post(graphqlUrl, { - query: fetchProjectsBySlugQuery, + query: fetchProjectBySlugQuery, variables: { slug: project1.slug, }, diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index a5fbfd56c..52da03b38 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -77,6 +77,7 @@ import { FilterProjectQueryInputParams, filterProjectsQuery, findProjectById, + findProjectBySlugWithoutAnyJoin, totalProjectsPerDate, totalProjectsPerDateByMonthAndYear, userIsOwnerOfProject, @@ -108,6 +109,8 @@ import { FeaturedUpdate } from '../entities/featuredUpdate'; import { PROJECT_UPDATE_CONTENT_MAX_LENGTH } from '../constants/validators'; import { calculateGivbackFactor } from '../services/givbackService'; import { ProjectBySlugResponse } from './types/projectResolver'; +import { findActiveQfRound } from '../repositories/qfRoundRepository'; +import { getAllProjectsRelatedToActiveCampaigns } from '../services/campaignService'; @ObjectType() class AllProjects { @@ -486,11 +489,6 @@ export class ProjectResolver { return query.andWhere(`project.${filter} ${acceptGiv} NULL`); } - if (filter === 'traceCampaignId') { - const isRequested = filterValue ? 'IS NOT' : 'IS'; - return query.andWhere(`project.${filter} ${isRequested} NULL`); - } - if ( (filter === FilterField.AcceptFundOnGnosis || filter === FilterField.AcceptFundOnCelo || @@ -764,6 +762,12 @@ export class ProjectResolver { ): Promise { let projects: Project[]; let totalCount: number; + let activeQfRoundId: number | undefined; + + if (sortingBy === SortingField.ActiveQfRoundRaisedFunds) { + activeQfRoundId = (await findActiveQfRound())?.id; + } + const filterQueryParams: FilterProjectQueryInputParams = { limit, skip, @@ -773,6 +777,7 @@ export class ProjectResolver { filters, sortingBy, qfRoundId, + activeQfRoundId, }; let campaign; if (campaignSlug) { @@ -903,11 +908,18 @@ export class ProjectResolver { isOwnerOfProject = await userIsOwnerOfProject(viewerUserId, slug); } + const minimalProject = await findProjectBySlugWithoutAnyJoin(slug); + if (!minimalProject) { + throw new Error(i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND)); + } + const campaignSlugs = (await getAllProjectsRelatedToActiveCampaigns())[ + minimalProject.id + ]; + let query = this.projectRepository .createQueryBuilder('project') - // check current slug and previous slugs - .where(`:slug = ANY(project."slugHistory") or project.slug = :slug`, { - slug, + .where(`project.id = :id`, { + id: minimalProject.id, }) .leftJoinAndSelect('project.status', 'status') .leftJoinAndSelect( @@ -923,6 +935,16 @@ export class ProjectResolver { .leftJoinAndSelect('project.projectInstantPower', 'projectInstantPower') .leftJoinAndSelect('project.qfRounds', 'qfRounds') .leftJoinAndSelect('project.projectFuturePower', 'projectFuturePower') + .leftJoinAndMapMany( + 'project.campaigns', + Campaign, + 'campaigns', + '((campaigns."relatedProjectsSlugs" && ARRAY[:slug]::text[] OR campaigns."relatedProjectsSlugs" && project."slugHistory") AND campaigns."isActive" = TRUE) OR (campaigns.slug = ANY(:campaignSlugs))', + { + slug, + campaignSlugs, + }, + ) .leftJoin('project.adminUser', 'user') .addSelect(publicSelectionFields); // aliased selection @@ -942,9 +964,6 @@ export class ProjectResolver { }); const project = await query.getOne(); - if (!project) { - throw new Error(i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND)); - } canUserVisitProject(project, String(user?.userId)); const verificationForm = project?.projectVerificationForm || @@ -952,7 +971,9 @@ export class ProjectResolver { if (verificationForm) { (project as Project).verificationFormStatus = verificationForm?.status; } - const { givbackFactor } = await calculateGivbackFactor(project.id); + + // We know that we have the project because if we reach this line means minimalProject is not null + const { givbackFactor } = await calculateGivbackFactor(project!.id); return { ...project, givbackFactor }; } diff --git a/src/resolvers/qfRoundHistoryResolver.test.ts b/src/resolvers/qfRoundHistoryResolver.test.ts index 3f4e9fa85..3fbf8b0a4 100644 --- a/src/resolvers/qfRoundHistoryResolver.test.ts +++ b/src/resolvers/qfRoundHistoryResolver.test.ts @@ -14,7 +14,7 @@ import moment from 'moment'; import { fillQfRoundHistory } from '../repositories/qfRoundHistoryRepository'; import axios from 'axios'; import { - fetchProjectsBySlugQuery, + fetchProjectBySlugQuery, getQfRoundHistoryQuery, } from '../../test/graphqlQueries'; diff --git a/src/server/adminJs/tabs/qfRoundTab.ts b/src/server/adminJs/tabs/qfRoundTab.ts index 4422c14f5..ded578459 100644 --- a/src/server/adminJs/tabs/qfRoundTab.ts +++ b/src/server/adminJs/tabs/qfRoundTab.ts @@ -19,6 +19,7 @@ import { getRelatedProjectsOfQfRound, } from '../../../repositories/qfRoundRepository'; import { RecordJSON } from 'adminjs/src/frontend/interfaces/record-json.interface'; +import { NETWORK_IDS } from '../../../provider'; export const refreshMaterializedViews = async ( response, @@ -70,6 +71,25 @@ export const qfRoundTab = { minimumPassportScore: { isVisible: true, }, + eligibleNetworks: { + isVisible: true, + type: 'array', + availableValues: [ + { value: NETWORK_IDS.MAIN_NET, label: 'MAINNET' }, + { value: NETWORK_IDS.ROPSTEN, label: 'ROPSTEN' }, + { value: NETWORK_IDS.GOERLI, label: 'GOERLI' }, + { value: NETWORK_IDS.POLYGON, label: 'POLYGON' }, + { value: NETWORK_IDS.OPTIMISTIC, label: 'OPTIMISTIC' }, + { value: NETWORK_IDS.OPTIMISM_GOERLI, label: 'OPTIMISM GOERLI' }, + { value: NETWORK_IDS.CELO, label: 'CELO' }, + { + value: NETWORK_IDS.CELO_ALFAJORES, + label: 'ALFAJORES (Test CELO)', + }, + { value: NETWORK_IDS.XDAI, label: 'XDAI' }, + { value: NETWORK_IDS.BSC, label: 'BSC' }, + ], + }, projects: { type: 'mixed', isVisible: { diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index f371922bc..0bdc59218 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -66,6 +66,8 @@ import { } from '../services/projectViewsService'; import { isTestEnv } from '../utils/utils'; import { runCheckActiveStatusOfQfRounds } from '../services/cronJobs/checkActiveStatusQfRounds'; +import { runUpdateProjectCampaignsCacheJob } from '../services/cronJobs/updateProjectCampaignsCacheJob'; +import { getAllProjectsRelatedToActiveCampaigns } from '../services/campaignService'; Resource.validate = validate; @@ -363,6 +365,20 @@ export async function bootstrap() { ) { runFillPowerSnapshotBalanceCronJob(); } + logger.debug('Running givPower cron jobs info ', { + UPDATE_POWER_SNAPSHOT_SERVICE_ACTIVE: config.get( + 'UPDATE_POWER_SNAPSHOT_SERVICE_ACTIVE', + ), + ENABLE_INSTANT_BOOSTING_UPDATE: config.get( + 'ENABLE_INSTANT_BOOSTING_UPDATE', + ), + INSTANT_BOOSTING_UPDATE_CRONJOB_EXPRESSION: config.get( + 'INSTANT_BOOSTING_UPDATE_CRONJOB_EXPRESSION', + ), + UPDATE_POWER_ROUND_CRONJOB_EXPRESSION: config.get( + 'UPDATE_POWER_ROUND_CRONJOB_EXPRESSION', + ), + }); if ( (config.get('UPDATE_POWER_SNAPSHOT_SERVICE_ACTIVE') as string) === 'true' ) { @@ -372,6 +388,7 @@ export async function bootstrap() { runInstantBoostingUpdateCronJob(); } await runCheckActiveStatusOfQfRounds(); + await runUpdateProjectCampaignsCacheJob(); } catch (err) { logger.error(err); } diff --git a/src/services/authorizationService.test.ts b/src/services/authorizationService.test.ts index a86dfdb59..34f589693 100644 --- a/src/services/authorizationService.test.ts +++ b/src/services/authorizationService.test.ts @@ -2,6 +2,7 @@ import { assert } from 'chai'; import { generateRandomEtheriumAddress, generateTestAccessToken, + saveUserDirectlyToDb, } from '../../test/testUtils'; import { User } from '../entities/user'; import Axios from 'axios'; @@ -20,15 +21,7 @@ const origin = 'https://serve.giveth.io'; function authorizationHandlerTestCases() { it('should decode user jwt with current impact graph authorization', async () => { - const userData = { - firstName: 'firstName', - lastName: 'lastName', - email: `${new Date().getTime()}-giveth@giveth.com`, - url: 'website url', - loginType: 'wallet', - walletAddress: generateRandomEtheriumAddress(), - }; - const user = await User.create(userData).save(); + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); const accessToken = await generateTestAccessToken(user.id); const jwtUser = await authorizationHandler('1', accessToken); assert.equal(jwtUser.userId, user.id); @@ -36,15 +29,8 @@ function authorizationHandlerTestCases() { it('should decode user jwt with the auth microservice', async () => { const privateKey = process.env.PRIVATE_ETHERS_TEST_KEY as string; const publicKey = process.env.PUBLIC_ETHERS_TEST_KEY as string; - const userData = { - firstName: 'firstName', - lastName: 'lastName', - email: `${new Date().getTime()}-giveth@giveth.com`, - url: 'website url', - loginType: 'wallet', - walletAddress: publicKey, - }; - const user = await User.create(userData).save(); + + const user = await saveUserDirectlyToDb(publicKey); const nonceRoute = config.get('AUTH_MICROSERVICE_NONCE_URL') as string; const nonceResult = await Axios.get(nonceRoute); const wallet = new ethers.Wallet(privateKey); @@ -73,6 +59,7 @@ function authorizationHandlerTestCases() { const accessToken = authenticationResult.data.jwt; const jwtUser = await authorizationHandler('2', accessToken); assert.equal(jwtUser.userId, user.id); + await User.delete(user.id); }); it('should decode jwt and create user if it is nonexistent', async () => { const privateKey = process.env.PRIVATE_ETHERS_SECONDARY_TEST_KEY as string; diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts index 1851d13af..129e0a0cc 100644 --- a/src/services/campaignService.ts +++ b/src/services/campaignService.ts @@ -7,16 +7,17 @@ import { FilterField, Project, SortingField } from '../entities/project'; import { findUserReactionsByProjectIds } from '../repositories/reactionRepository'; import { ModuleThread, Pool } from 'threads'; import { ProjectResolverWorker } from '../workers/projectsResolverWorker'; +import { QueryBuilder } from 'typeorm/query-builder/QueryBuilder'; +import { findAllActiveCampaigns } from '../repositories/campaignRepository'; +import { logger } from '../utils/logger'; +import { getRedisObject, setObjectInRedis } from '../redis'; const projectFiltersCacheDuration = Number(process.env.PROJECT_FILTERS_THREADS_POOL_DURATION) || 60000; -export const fillCampaignProjects = async (params: { - userId?: number; - campaign: Campaign; - projectsFiltersThreadPool: Pool>; -}): Promise => { - const { campaign, userId, projectsFiltersThreadPool } = params; +const createFetchCampaignProjectsQuery = ( + campaign: Campaign, +): FilterProjectQueryInputParams | null => { const limit = 10; const skip = 0; const projectsQueryParams: FilterProjectQueryInputParams = { @@ -36,9 +37,63 @@ export const fillCampaignProjects = async (params: { campaign.sortingField as unknown as SortingField; } else if (campaign.type === CampaignType.WithoutProjects) { // Dont add projects to this campaign type - return campaign; + return null; + } + + return projectsQueryParams; +}; +const PROJECT_CAMPAIGN_CACHE_REDIS_KEY = + 'projectCampaignCache-for-projectBySlug'; + +export const getAllProjectsRelatedToActiveCampaigns = async (): Promise<{ + [key: number]: string[]; +}> => { + const projectCampaignCache = await getRedisObject( + PROJECT_CAMPAIGN_CACHE_REDIS_KEY, + ); + // It returns all project and campaigns( excluding manuallySelectedCampaign) + return projectCampaignCache || {}; +}; + +export const cacheProjectCampaigns = async (): Promise => { + logger.debug('cacheProjectCampaigns() has been called'); + const newProjectCampaignCache = {}; + const activeCampaigns = await findAllActiveCampaigns(); + for (const campaign of activeCampaigns) { + const projectsQueryParams = createFetchCampaignProjectsQuery(campaign); + if (!projectsQueryParams) { + continue; + } + const projectsQuery = filterProjectsQuery(projectsQueryParams); + const projects = await projectsQuery.getMany(); + for (const project of projects) { + newProjectCampaignCache[project.id] + ? newProjectCampaignCache[project.id].push(campaign.slug) + : (newProjectCampaignCache[project.id] = [campaign.slug]); + } } + await setObjectInRedis({ + key: PROJECT_CAMPAIGN_CACHE_REDIS_KEY, + value: newProjectCampaignCache, + // cronjob would fill it every 10 minutes so the expiration doesnt matter + expiration: 60 * 60 * 24 * 1, // 1 day + }); + logger.debug( + 'cacheProjectCampaigns() ended successfully, projectCampaignCache size ', + Object.keys(newProjectCampaignCache).length, + ); +}; +export const fillCampaignProjects = async (params: { + userId?: number; + campaign: Campaign; + projectsFiltersThreadPool: Pool>; +}): Promise => { + const { campaign, userId, projectsFiltersThreadPool } = params; + const projectsQueryParams = createFetchCampaignProjectsQuery(campaign); + if (!projectsQueryParams) { + return campaign; + } const projectsQuery = filterProjectsQuery(projectsQueryParams); const projectsQueryCacheKey = await projectsFiltersThreadPool.queue(hasher => hasher.hashProjectFilters({ diff --git a/src/services/cronJobs/checkActiveStatusQfRounds.ts b/src/services/cronJobs/checkActiveStatusQfRounds.ts index 584700b91..b9519bd1b 100644 --- a/src/services/cronJobs/checkActiveStatusQfRounds.ts +++ b/src/services/cronJobs/checkActiveStatusQfRounds.ts @@ -1,23 +1,6 @@ import config from '../../config'; import { logger } from '../../utils/logger'; import { schedule } from 'node-cron'; -import { - getPowerRound, - setPowerRound, -} from '../../repositories/powerRoundRepository'; -import { getRoundNumberByDate } from '../../utils/powerBoostingUtils'; -import { - refreshProjectPowerView, - refreshProjectFuturePowerView, - getBottomRank, -} from '../../repositories/projectPowerViewRepository'; -import { refreshUserProjectPowerView } from '../../repositories/userProjectPowerViewRepository'; -import { - copyProjectRanksToPreviousRoundRankTable, - deleteAllPreviousRoundRanks, - projectsThatTheirRanksHaveChanged, -} from '../../repositories/previousRoundRankRepository'; -import { getNotificationAdapter } from '../../adapters/adaptersFactory'; import { isTestEnv, sleep } from '../../utils/utils'; import { deactivateExpiredQfRounds, diff --git a/src/services/cronJobs/updateProjectCampaignsCacheJob.ts b/src/services/cronJobs/updateProjectCampaignsCacheJob.ts new file mode 100644 index 000000000..0c1d24e6a --- /dev/null +++ b/src/services/cronJobs/updateProjectCampaignsCacheJob.ts @@ -0,0 +1,37 @@ +import config from '../../config'; +import { logger } from '../../utils/logger'; +import { schedule } from 'node-cron'; +import { isTestEnv } from '../../utils/utils'; +import { ModuleThread, Pool, spawn, Worker } from 'threads'; +import { CacheProjectCampaignsWorker } from '../../workers/cacheProjectCampaignsWorker'; + +// every 10 minutes +const cronJobTime = + (config.get('CACHE_PROJECT_CAMPAIGNS_CRONJOB_EXPRESSION') as string) || + '0 */5 * * * *'; + +const projectsFiltersThreadPool: Pool< + ModuleThread +> = Pool( + () => spawn(new Worker('../../workers/cacheProjectCampaignsWorker')), // create the worker, +); +export const runUpdateProjectCampaignsCacheJob = () => { + // Run it first time to make sure it is cached + projectsFiltersThreadPool.queue(async worker => { + await worker.cacheSlugsOfCampaignProjects(); + }); + + logger.debug( + 'runUpdateProjectCampaignsCacheJob() has been called, cronJobTime', + cronJobTime, + ); + schedule(cronJobTime, async () => { + try { + projectsFiltersThreadPool.queue(async worker => { + await worker.cacheSlugsOfCampaignProjects(); + }); + } catch (e) { + logger.error('runUpdateProjectCampaignsCacheJob() error', e); + } + }); +}; diff --git a/src/services/donationService.test.ts b/src/services/donationService.test.ts index ad9f81a3d..0f1b83eeb 100644 --- a/src/services/donationService.test.ts +++ b/src/services/donationService.test.ts @@ -12,6 +12,7 @@ import { createDonationData, createProjectData, DONATION_SEED_DATA, + generateRandomEtheriumAddress, saveDonationDirectlyToDb, saveProjectDirectlyToDb, saveUserDirectlyToDb, @@ -25,6 +26,7 @@ import { errorMessages } from '../utils/errorMessages'; import { findDonationById } from '../repositories/donationRepository'; import { findProjectById } from '../repositories/projectRepository'; import { CHAIN_ID } from '@giveth/monoswap/dist/src/sdk/sdkFactory'; +import { findUserById } from '../repositories/userRepository'; describe('isProjectAcceptToken test cases', isProjectAcceptTokenTestCases); describe( @@ -64,7 +66,7 @@ function sendSegmentEventForDonationTestCases() { } function syncDonationStatusWithBlockchainNetworkTestCases() { - it('should verify a goerli donation', async () => { + it('should verify a goerli donation and update donor.totalDonated and projectOwner.totalReceived', async () => { // https://goerli.etherscan.io/tx/0x43cb1c61a81f007abd3de766a6029ffe62d0324268d7781469a3d7879d487cb1 const transactionInfo = { @@ -78,10 +80,16 @@ function syncDonationStatusWithBlockchainNetworkTestCases() { timestamp: 1661114988, }; const user = await saveUserDirectlyToDb(transactionInfo.fromAddress); - const project = await saveProjectDirectlyToDb({ - ...createProjectData(), - walletAddress: transactionInfo.toAddress, - }); + const projectOwner = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + const project = await saveProjectDirectlyToDb( + { + ...createProjectData(), + walletAddress: transactionInfo.toAddress, + }, + projectOwner, + ); const donation = await saveDonationDirectlyToDb( { amount: transactionInfo.amount, @@ -105,6 +113,12 @@ function syncDonationStatusWithBlockchainNetworkTestCases() { assert.equal(updateDonation.id, donation.id); assert.isTrue(updateDonation.segmentNotified); assert.equal(updateDonation.status, DONATION_STATUS.VERIFIED); + + const donor = await findUserById(user.id); + assert.equal(donor?.totalDonated, 100); + + const updatedProjectOwner = await findUserById(projectOwner.id); + assert.equal(updatedProjectOwner?.totalReceived, 100); }); it('should verify a Polygon donation', async () => { diff --git a/src/services/donationService.ts b/src/services/donationService.ts index 7041df901..9097ff8f1 100644 --- a/src/services/donationService.ts +++ b/src/services/donationService.ts @@ -323,7 +323,8 @@ export const syncDonationStatusWithBlockchainNetwork = async (params: { // After updating price we update totalDonations await updateTotalDonationsOfProject(donation.projectId); - await updateUserTotalReceived(donation.userId); + const project = await findProjectById(donation.projectId); + await updateUserTotalReceived(project!.adminUser.id); await sendSegmentEventForDonation({ donation, }); diff --git a/src/services/instantBoostingServices.ts b/src/services/instantBoostingServices.ts index 993a8c554..010755b9f 100644 --- a/src/services/instantBoostingServices.ts +++ b/src/services/instantBoostingServices.ts @@ -17,7 +17,14 @@ import { export const updateInstantBoosting = async (): Promise => { logger.debug('updateInstantBoosting() has been called'); - await updateInstantPowerBalances(); + try { + await updateInstantPowerBalances(); + } catch (e) { + logger.error( + 'updateInstantBoosting() calling updateInstantPowerBalances() error', + e, + ); + } await refreshProjectInstantPowerView(); // await refreshProjectUserInstantPowerView(); }; diff --git a/src/services/userService.ts b/src/services/userService.ts index 0eec72187..7bef7a01c 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -27,18 +27,18 @@ export const updateUserTotalDonated = async (userId: number) => { export const updateUserTotalReceived = async (userId: number) => { try { - await User.query( - ` - UPDATE "user" - SET "totalReceived" = ( - SELECT COALESCE(SUM(p."totalDonations"),0) - FROM project as p - WHERE p."adminUserId" = $1 - ) - WHERE "id" = $1 - `, - [userId], - ); + const totalReceived = await User.createQueryBuilder('user') + .select('COALESCE(SUM(project.totalDonations), 0)', 'totalReceived') + .leftJoin('project', 'project', 'project.adminUserId = user.id') + .where('user.id = :userId', { userId }) + .addGroupBy('user.id') + .getRawOne(); + + await User.createQueryBuilder() + .update(User) + .set({ totalReceived: totalReceived.totalReceived }) + .where('id = :userId', { userId }) + .execute(); } catch (e) { logger.error('updateUserTotalReceived() error', e); } diff --git a/src/workers/cacheProjectCampaignsWorker.ts b/src/workers/cacheProjectCampaignsWorker.ts new file mode 100644 index 000000000..87b3568a9 --- /dev/null +++ b/src/workers/cacheProjectCampaignsWorker.ts @@ -0,0 +1,17 @@ +// workers/auth.js +import { expose } from 'threads/worker'; +import { WorkerModule } from 'threads/dist/types/worker'; +import { cacheProjectCampaigns } from '../services/campaignService'; + +type ProjectsResolverWorkerFunctions = 'cacheSlugsOfCampaignProjects'; + +export type CacheProjectCampaignsWorker = + WorkerModule; + +const worker: CacheProjectCampaignsWorker = { + async cacheSlugsOfCampaignProjects() { + await cacheProjectCampaigns(); + }, +}; + +expose(worker); diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 247ef73fe..10ad579dd 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -782,7 +782,7 @@ export const getQfRoundHistoryQuery = ` } `; -export const fetchProjectsBySlugQuery = ` +export const fetchProjectBySlugQuery = ` query ( $slug: String! ) { @@ -806,6 +806,24 @@ export const fetchProjectsBySlugQuery = ` listed reviewStatus givingBlocksId + campaigns { + id + title + description + type + photo + video + videoPreview + slug + isActive + order + landingLink + filterFields + sortingField + createdAt + updatedAt + + } givbackFactor projectPower { totalPower @@ -1998,3 +2016,17 @@ export const getProjectUserInstantPowerQuery = ` } } `; + +export const doesDonatedToProjectInQfRoundQuery = ` + query ( + $projectId: Int!, + $qfRoundId: Int!, + $userId: Int! + ) { + doesDonatedToProjectInQfRound( + projectId: $projectId + qfRoundId: $qfRoundId + userId: $userId + ) + } +`;