From 0f760e0da96aaf70138c38117a063c97fe72d2c3 Mon Sep 17 00:00:00 2001 From: Ali Ebrahimi <65724329+ae2079@users.noreply.github.com> Date: Wed, 17 Jul 2024 12:24:58 +0330 Subject: [PATCH] Release relevant donations (#1687) * get donation to giveth with donation box analytics (#1661) * Add a method for finding relevant donations to the donationRepository.ts * Add a method for calculating donations metrics to the donationService.ts * Add a gql resolver for donations metrics to the donationResolver.ts * Add unit test for gql query resolver * Fix division by zero issue * times 100 average percentage to represent percent * add useDonationBox field to donation and draftDonation * fill useDonationBox field with correct data in migrations * Change donationMetrics endpoint based on change in data schema * remove unused import Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * remove unused import Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add default value for useDonationBox field Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add false removed imports * add default value for useDonationBox field Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix tests based on changes * fix tests * send useDonationBox as last arg and make it optional * add relevant donation tx hash to donation creation flow * calculate related donations based on relevant donation tx hash * add unit tests * update unit test * fix bug * fix bug in donation repository and tests are pass * Clear All donations after donation metrics test cases * Add new function for deleting project from db and use it to don't affect other test cases * comment new test cases to ensure bug is related to that or not * Clear donations after tests * Comments new test cases in donationRepository.test.ts * uncomment tests and Clean up test effects on DB * Remove project addresses too * Add a new method to testUtils.ts for removing project from db by id * change donation dates to now * Remove clear donations sections * Change update count to a bigger number to pass test --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Remove old donation and add new ones (#1674) * Remove unused variable --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- ...68639-addUseDonationBoxToDraftDonations.ts | 31 ++++ ...eDonationBoxAndRelevantTxHashToDonation.ts | 41 ++++++ ...eDonationBoxAndRelevantTxHashInDonation.ts | 136 ++++++++++++++++++ .../DonationSaveBackupInterface.ts | 2 + src/entities/donation.ts | 8 ++ src/entities/draftDonation.ts | 8 ++ src/repositories/donationRepository.test.ts | 87 +++++++++++ src/repositories/donationRepository.ts | 36 ++++- src/resolvers/donationResolver.test.ts | 97 +++++++++++++ src/resolvers/donationResolver.ts | 43 ++++++ src/resolvers/draftDonationResolver.ts | 8 ++ src/resolvers/projectResolver.test.ts | 4 +- .../cronJobs/backupDonationImportJob.ts | 5 + src/services/donationService.ts | 46 +++++- .../validators/graphqlQueryValidators.ts | 4 + test/graphqlQueries.ts | 16 +++ test/testUtils.ts | 26 +++- 17 files changed, 592 insertions(+), 6 deletions(-) create mode 100644 migration/1719887968639-addUseDonationBoxToDraftDonations.ts create mode 100644 migration/1720634068179-addUseDonationBoxAndRelevantTxHashToDonation.ts create mode 100644 migration/1720634181001-fillUseDonationBoxAndRelevantTxHashInDonation.ts diff --git a/migration/1719887968639-addUseDonationBoxToDraftDonations.ts b/migration/1719887968639-addUseDonationBoxToDraftDonations.ts new file mode 100644 index 000000000..e2235a8a5 --- /dev/null +++ b/migration/1719887968639-addUseDonationBoxToDraftDonations.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddUseDonationBoxToDraftDonations1719887968639 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'draft_donation', + new TableColumn({ + name: 'useDonationBox', + type: 'boolean', + isNullable: true, + default: false, + }), + ); + + await queryRunner.addColumn( + 'draft_donation', + new TableColumn({ + name: 'relevantDonationTxHash', + type: 'varchar', + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('draft_donation', 'useDonationBox'); + await queryRunner.dropColumn('draft_donation', 'relevantDonationTxHash'); + } +} diff --git a/migration/1720634068179-addUseDonationBoxAndRelevantTxHashToDonation.ts b/migration/1720634068179-addUseDonationBoxAndRelevantTxHashToDonation.ts new file mode 100644 index 000000000..9cb54e2f7 --- /dev/null +++ b/migration/1720634068179-addUseDonationBoxAndRelevantTxHashToDonation.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddUseDonationBoxAndRelevantTxHashToDonation1720634068179 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('donation'); + const useDonationBoxColumn = table?.findColumnByName('useDonationBox'); + const relevantDonationTxHashColumn = table?.findColumnByName( + 'relevantDonationTxHash', + ); + + if (!useDonationBoxColumn) { + await queryRunner.addColumn( + 'donation', + new TableColumn({ + name: 'useDonationBox', + type: 'boolean', + isNullable: true, + default: false, + }), + ); + } + + if (!relevantDonationTxHashColumn) { + await queryRunner.addColumn( + 'donation', + new TableColumn({ + name: 'relevantDonationTxHash', + type: 'varchar', + isNullable: true, + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('donation', 'useDonationBox'); + await queryRunner.dropColumn('donation', 'relevantDonationTxHash'); + } +} diff --git a/migration/1720634181001-fillUseDonationBoxAndRelevantTxHashInDonation.ts b/migration/1720634181001-fillUseDonationBoxAndRelevantTxHashInDonation.ts new file mode 100644 index 000000000..3d3018722 --- /dev/null +++ b/migration/1720634181001-fillUseDonationBoxAndRelevantTxHashInDonation.ts @@ -0,0 +1,136 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +interface DonationUpdate { + id: number; + useDonationBox: boolean; + relevantDonationTxHash: string | null; +} + +export class FillUseDonationBoxAndRelevantTxHashInDonation1720634181001 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const givethProjectId = 1; + const timeDiff = 60 * 1000; // 1 minute in milliseconds + + // Load all donations into memory + const donations = await queryRunner.query( + `SELECT id, "userId", "projectId", "createdAt", "transactionId" FROM donation`, + ); + + // Calculate relevant donations + const updates: DonationUpdate[] = []; + const userDonations = donations.reduce( + (acc, donation) => { + const userId = donation.userId; + if (!acc[userId]) { + acc[userId] = []; + } + acc[userId].push(donation); + return acc; + }, + {} as Record, + ); + + for (const userId in userDonations) { + const userDonationList = userDonations[userId]; + userDonationList.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + for (let i = 0; i < userDonationList.length; i++) { + const donation = userDonationList[i]; + if (donation.projectId === givethProjectId) { + let found = false; + + // Check for donations after the current donation + for (let j = i + 1; j < userDonationList.length; j++) { + const nextDonation = userDonationList[j]; + const timeDifference = + new Date(nextDonation.createdAt).getTime() - + new Date(donation.createdAt).getTime(); + if (timeDifference <= timeDiff) { + if (nextDonation.projectId !== givethProjectId) { + updates.push({ + id: donation.id, + useDonationBox: true, + relevantDonationTxHash: nextDonation.transactionId, + }); + updates.push({ + id: nextDonation.id, + useDonationBox: true, + relevantDonationTxHash: null, + }); + found = true; + break; + } + } else { + break; + } + } + + // Check for donations before the current donation if no relevant donation found + if (!found) { + for (let k = i - 1; k >= 0; k--) { + const prevDonation = userDonationList[k]; + const timeDifference = + new Date(donation.createdAt).getTime() - + new Date(prevDonation.createdAt).getTime(); + if (timeDifference <= timeDiff) { + if (prevDonation.projectId !== givethProjectId) { + updates.push({ + id: donation.id, + useDonationBox: true, + relevantDonationTxHash: prevDonation.transactionId, + }); + updates.push({ + id: prevDonation.id, + useDonationBox: true, + relevantDonationTxHash: null, + }); + break; + } + } else { + break; + } + } + } + } + } + } + + // Perform batch update using a single query + const updateQuery = updates + .map( + update => + `(${update.id}, ${update.useDonationBox}, ${ + update.relevantDonationTxHash + ? `'${update.relevantDonationTxHash}'` + : 'NULL' + })`, + ) + .join(', '); + + await queryRunner.query( + ` + UPDATE donation AS d + SET "useDonationBox" = u."useDonationBox", + "relevantDonationTxHash" = u."relevantDonationTxHash" + FROM (VALUES ${updateQuery}) AS u(id, "useDonationBox", "relevantDonationTxHash") + WHERE d.id = u.id; + `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + ` + UPDATE donation + SET "useDonationBox" = false, + "relevantDonationTxHash" = NULL + WHERE "useDonationBox" = true; + `, + ); + } +} diff --git a/src/adapters/donationSaveBackup/DonationSaveBackupInterface.ts b/src/adapters/donationSaveBackup/DonationSaveBackupInterface.ts index 20acc1f4f..9e11fe4df 100644 --- a/src/adapters/donationSaveBackup/DonationSaveBackupInterface.ts +++ b/src/adapters/donationSaveBackup/DonationSaveBackupInterface.ts @@ -17,6 +17,8 @@ export type FetchedSavedFailDonationInterface = { symbol: string; chainvineReferred?: string; safeTransactionId?: string; + useDonationBox?: boolean; + relevantDonationTxHash?: string; }; export interface DonationSaveBackupInterface { diff --git a/src/entities/donation.ts b/src/entities/donation.ts index 631811783..cb665d8c7 100644 --- a/src/entities/donation.ts +++ b/src/entities/donation.ts @@ -279,6 +279,14 @@ export class Donation extends BaseEntity { // To match the superFluid Virtual Period virtualPeriodEnd?: number; + @Field({ nullable: true }) + @Column('boolean', { nullable: true, default: false }) + useDonationBox?: boolean; + + @Field({ nullable: true }) + @Column({ nullable: true }) + relevantDonationTxHash?: string; + static async findXdaiGivDonationsWithoutPrice() { return this.createQueryBuilder('donation') .where(`donation.currency = 'GIV' AND donation."valueUsd" IS NULL `) diff --git a/src/entities/draftDonation.ts b/src/entities/draftDonation.ts index b5cb2e73e..9fc0e1f3f 100644 --- a/src/entities/draftDonation.ts +++ b/src/entities/draftDonation.ts @@ -111,4 +111,12 @@ export class DraftDonation extends BaseEntity { @Field() @Column({ nullable: true }) matchedDonationId?: number; + + @Field() + @Column({ nullable: true, default: false }) + useDonationBox?: boolean; + + @Field() + @Column({ nullable: true }) + relevantDonationTxHash?: string; } diff --git a/src/repositories/donationRepository.test.ts b/src/repositories/donationRepository.test.ts index cf4f8f963..b3576617e 100644 --- a/src/repositories/donationRepository.test.ts +++ b/src/repositories/donationRepository.test.ts @@ -1,8 +1,10 @@ import { assert } from 'chai'; import moment from 'moment'; import { + assertThrowsAsync, createDonationData, createProjectData, + deleteProjectDirectlyFromDb, generateRandomEtheriumAddress, generateRandomEvmTxHash, saveDonationDirectlyToDb, @@ -21,6 +23,7 @@ import { findStableCoinDonationsWithoutPrice, getPendingDonationsIds, isVerifiedDonationExistsInQfRound, + findRelevantDonations, sumDonationValueUsd, sumDonationValueUsdForQfRound, } from './donationRepository'; @@ -69,6 +72,7 @@ describe( 'isVerifiedDonationExistsInQfRound() test cases', isVerifiedDonationExistsInQfRoundTestCases, ); +describe('findRelevantDonations', findRelevantDonationsTestCases); function fillQfRoundDonationsUserScoresTestCases() { let qfRound: QfRound; @@ -1509,3 +1513,86 @@ function isVerifiedDonationExistsInQfRoundTestCases() { await qfRound.save(); }); } + +function findRelevantDonationsTestCases() { + it('should return relevant donations correctly', async () => { + const project1 = await saveProjectDirectlyToDb(createProjectData()); + const project2 = await saveProjectDirectlyToDb(createProjectData()); + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + const donation1 = await saveDonationDirectlyToDb( + { + ...createDonationData(), + projectId: project1.id, + createdAt: new Date(), + relevantDonationTxHash: 'tx1', + useDonationBox: true, + }, + user.id, + project1.id, + ); + + const donation2 = await saveDonationDirectlyToDb( + { + ...createDonationData(), + projectId: project2.id, + createdAt: new Date(), + transactionId: 'tx1', + useDonationBox: true, + }, + user.id, + project2.id, + ); + + const { donationsToGiveth, pairedDonations } = await findRelevantDonations( + new Date('2023-01-01'), + new Date(), + project1.id, + ); + + assert.equal(donationsToGiveth.length, 1); + assert.equal(pairedDonations.length, 1); + assert.equal(donationsToGiveth[0].id, donation1.id); + assert.equal(pairedDonations[0].id, donation2.id); + + // Clean up + await Donation.remove([donation1, donation2]); + await deleteProjectDirectlyFromDb(project1.id); + await deleteProjectDirectlyFromDb(project2.id); + await User.remove(user); + }); + + it('should throw an error if the relevant donation does not exist', async () => { + // Create project and user + const givethProject = await saveProjectDirectlyToDb(createProjectData()); + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + const donationsToGiveth = await saveDonationDirectlyToDb( + { + ...createDonationData(), + projectId: givethProject.id, + createdAt: new Date(), + relevantDonationTxHash: 'tx1', + useDonationBox: true, + }, + user.id, + givethProject.id, + ); + + // Fetch relevant donations and expect an error + await assertThrowsAsync( + () => + findRelevantDonations( + new Date('2023-01-01'), + new Date(), + givethProject.id, + ), + `the relevant donation to this donation does not exist: donation id = ${donationsToGiveth.id}`, + ); + + // Clean up + await Donation.remove(donationsToGiveth); + await deleteProjectDirectlyFromDb(givethProject.id); + await User.remove(user); + }); +} diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index b7f8e53a8..53e365c32 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -1,4 +1,4 @@ -import { MoreThan } from 'typeorm'; +import { Between, MoreThan } from 'typeorm'; import moment from 'moment'; import { Project } from '../entities/project'; import { Donation, DONATION_STATUS } from '../entities/donation'; @@ -560,3 +560,37 @@ export async function isVerifiedDonationExistsInQfRound(params: { return false; } } + +export async function findRelevantDonations( + startDate: Date, + endDate: Date, + givethProjectId: number, +): Promise<{ donationsToGiveth: Donation[]; pairedDonations: Donation[] }> { + const donations = await Donation.find({ + where: { + createdAt: Between(startDate, endDate), + useDonationBox: true, + }, + }); + + const donationsToGiveth: Donation[] = []; + const pairedDonations: Donation[] = []; + + donations.forEach(donation => { + if (donation.projectId === givethProjectId) { + donationsToGiveth.push(donation); + const relevantDonation = donations.find( + d => d.transactionId === donation.relevantDonationTxHash, + ); + if (relevantDonation) { + pairedDonations.push(relevantDonation); + } else { + throw new Error( + `the relevant donation to this donation does not exist: donation id = ${donation.id}`, + ); + } + } + }); + + return { donationsToGiveth, pairedDonations }; +} diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index 702dcbc27..436db226d 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -15,6 +15,7 @@ import { generateUserIdLessAccessToken, generateRandomSolanaAddress, generateRandomSolanaTxHash, + deleteProjectDirectlyFromDb, } from '../../test/testUtils'; import { errorMessages } from '../utils/errorMessages'; import { Donation, DONATION_STATUS } from '../entities/donation'; @@ -34,6 +35,7 @@ import { doesDonatedToProjectInQfRoundQuery, fetchNewDonorsCount, fetchNewDonorsDonationTotalUsd, + fetchDonationMetricsQuery, } from '../../test/graphqlQueries'; import { NETWORK_IDS } from '../provider'; import { User } from '../entities/user'; @@ -96,6 +98,7 @@ describe( totalDonationsPerCategoryPerDateTestCases, ); describe('recentDonations() test cases', recentDonationsTestCases); +describe('donationMetrics() test cases', donationMetricsTestCases); // // describe('tokens() test cases', tokensTestCases); @@ -4787,3 +4790,97 @@ async function recentDonationsTestCases() { assert.equal(recentDonations[1].id, donation2.id); }); } + +async function donationMetricsTestCases() { + it('should return correct donation metrics', async () => { + const walletAddress1 = generateRandomEtheriumAddress(); + const walletAddress2 = generateRandomEtheriumAddress(); + const project1 = await saveProjectDirectlyToDb(createProjectData('giveth')); + const project2 = await saveProjectDirectlyToDb(createProjectData()); + const user1 = await saveUserDirectlyToDb(walletAddress1); + const user2 = await saveUserDirectlyToDb(walletAddress2); + + // Donations to project with ID 1 (giveth) + const donation1 = await saveDonationDirectlyToDb( + { + ...createDonationData({ + status: DONATION_STATUS.VERIFIED, + createdAt: new Date('2024-01-01T00:00:00Z'), + valueUsd: 100, + }), + useDonationBox: true, + relevantDonationTxHash: 'tx1', + }, + user1.id, + project1.id, + ); + + const donation2 = await saveDonationDirectlyToDb( + { + ...createDonationData({ + status: DONATION_STATUS.VERIFIED, + createdAt: new Date('2024-01-01T00:00:30Z'), + valueUsd: 50, + }), + useDonationBox: true, + relevantDonationTxHash: 'tx2', + }, + user1.id, + project1.id, + ); + + // Donations to another project + const donation3 = await saveDonationDirectlyToDb( + { + ...createDonationData({ + status: DONATION_STATUS.VERIFIED, + createdAt: new Date('2024-01-01T00:01:00Z'), + valueUsd: 900, + }), + useDonationBox: true, + transactionId: 'tx1', + }, + user1.id, + project2.id, + ); + + const donation4 = await saveDonationDirectlyToDb( + { + ...createDonationData({ + status: DONATION_STATUS.VERIFIED, + createdAt: new Date('2023-01-01T00:01:30Z'), + valueUsd: 200, + }), + useDonationBox: true, + transactionId: 'tx2', + }, + user2.id, + project2.id, + ); + + const result = await axios.post( + graphqlUrl, + { + query: fetchDonationMetricsQuery, + variables: { + startDate: '2023-01-01T00:00:00Z', + endDate: '2025-01-02T00:00:00Z', + }, + }, + {}, + ); + + assert.isOk(result); + + const { donationMetrics } = result.data.data; + assert.equal(donationMetrics.totalDonationsToGiveth, 2); + assert.equal(donationMetrics.totalUsdValueToGiveth, 150); + assert.closeTo(donationMetrics.averagePercentageToGiveth, 15, 0.0001); + + // Clean up + await Donation.remove([donation1, donation2, donation3, donation4]); + await deleteProjectDirectlyFromDb(project1.id); + await deleteProjectDirectlyFromDb(project2.id); + await User.remove([user1, user2]); + }); +} diff --git a/src/resolvers/donationResolver.ts b/src/resolvers/donationResolver.ts index 0540d7f22..6e10dcc6f 100644 --- a/src/resolvers/donationResolver.ts +++ b/src/resolvers/donationResolver.ts @@ -4,6 +4,7 @@ import { ArgsType, Ctx, Field, + Float, InputType, Int, Mutation, @@ -24,6 +25,7 @@ import SentryLogger from '../sentryLogger'; import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages'; import { NETWORK_IDS } from '../provider'; import { + getDonationToGivethWithDonationBoxMetrics, isTokenAcceptableForProject, syncDonationStatusWithBlockchainNetwork, updateDonationPricesAndValues, @@ -200,6 +202,18 @@ class DonationCurrencyStats { currencyPercentage?: number; } +@ObjectType() +class DonationMetrics { + @Field(_type => Int, { nullable: true }) + totalDonationsToGiveth: number; + + @Field(_type => Float, { nullable: true }) + totalUsdValueToGiveth: number; + + @Field(_type => Float, { nullable: true }) + averagePercentageToGiveth: number; +} + @Resolver(_of => User) export class DonationResolver { private readonly donationRepository: Repository; @@ -676,6 +690,10 @@ export class DonationResolver { @Arg('referrerId', { nullable: true }) referrerId?: string, @Arg('safeTransactionId', { nullable: true }) safeTransactionId?: string, @Arg('draftDonationId', { nullable: true }) draftDonationId?: number, + @Arg('useDonationBox', { nullable: true, defaultValue: false }) + useDonationBox?: boolean, + @Arg('relevantDonationTxHash', { nullable: true }) + relevantDonationTxHash?: string, ): Promise { const logData = { amount, @@ -719,6 +737,8 @@ export class DonationResolver { referrerId, safeTransactionId, chainType, + useDonationBox, + relevantDonationTxHash, }; try { validateWithJoiSchema(validaDataInput, createDonationQueryValidator); @@ -817,6 +837,8 @@ export class DonationResolver { anonymous: Boolean(anonymous), safeTransactionId, chainType: chainType as ChainType, + useDonationBox, + relevantDonationTxHash, }); if (referrerId) { // Fill referrer data if referrerId is valid @@ -999,4 +1021,25 @@ export class DonationResolver { userId, }); } + + @Query(_returns => DonationMetrics) + async donationMetrics( + @Arg('startDate', _type => String, { nullable: false }) startDate: string, + @Arg('endDate', _type => String, { nullable: false }) endDate: string, + ): Promise { + try { + const metrics = await getDonationToGivethWithDonationBoxMetrics( + new Date(startDate), + new Date(endDate), + ); + return { + totalDonationsToGiveth: metrics.totalDonationsToGiveth, + totalUsdValueToGiveth: metrics.totalUsdValueToGiveth, + averagePercentageToGiveth: metrics.averagePercentageToGiveth, + }; + } catch (e) { + logger.error('donationMetrics query error', e); + throw e; + } + } } diff --git a/src/resolvers/draftDonationResolver.ts b/src/resolvers/draftDonationResolver.ts index 5aae6e53a..425e39083 100644 --- a/src/resolvers/draftDonationResolver.ts +++ b/src/resolvers/draftDonationResolver.ts @@ -52,6 +52,10 @@ export class DraftDonationResolver { @Ctx() ctx: ApolloContext, @Arg('referrerId', { nullable: true }) referrerId?: string, @Arg('safeTransactionId', { nullable: true }) safeTransactionId?: string, + @Arg('useDonationBox', { nullable: true, defaultValue: false }) + useDonationBox?: boolean, + @Arg('relevantDonationTxHash', { nullable: true }) + relevantDonationTxHash?: string, ): Promise { const logData = { amount, @@ -94,6 +98,8 @@ export class DraftDonationResolver { referrerId, safeTransactionId, chainType, + useDonationBox, + relevantDonationTxHash, }; try { validateWithJoiSchema( @@ -133,6 +139,8 @@ export class DraftDonationResolver { anonymous: Boolean(anonymous), chainType: chainType as ChainType, referrerId, + useDonationBox, + relevantDonationTxHash, }) .orIgnore() .returning('id') diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index a2c90da77..25c55bdd7 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -5491,7 +5491,7 @@ function editProjectUpdateTestCases() { { query: editProjectUpdateQuery, variables: { - updateId: Number(projectUpdateCount + 1), + updateId: Number(projectUpdateCount + 10), content: 'TestProjectUpdateFateme2', title: 'testEditProjectUpdateFateme2', }, @@ -5606,7 +5606,7 @@ function deleteProjectUpdateTestCases() { { query: deleteProjectUpdateQuery, variables: { - updateId: Number(projectUpdateCount + 2), + updateId: Number(projectUpdateCount + 10), }, }, { diff --git a/src/services/cronJobs/backupDonationImportJob.ts b/src/services/cronJobs/backupDonationImportJob.ts index 8071025f6..9b2f3e826 100644 --- a/src/services/cronJobs/backupDonationImportJob.ts +++ b/src/services/cronJobs/backupDonationImportJob.ts @@ -84,6 +84,8 @@ export const createBackupDonation = async ( nonce, safeTransactionId, chainvineReferred, + useDonationBox, + relevantDonationTxHash, } = donationData; const chainId = donationData?.chainId || donationData.token.networkId; @@ -109,6 +111,9 @@ export const createBackupDonation = async ( } as ApolloContext, chainvineReferred, safeTransactionId, + undefined, + useDonationBox, + relevantDonationTxHash, ); const donation = (await findDonationById(Number(donationId))) as Donation; donation!.createdAt = getCreatedAtFromMongoObjectId(donationData._id); diff --git a/src/services/donationService.ts b/src/services/donationService.ts index d7a2dea41..57db9ec16 100644 --- a/src/services/donationService.ts +++ b/src/services/donationService.ts @@ -13,11 +13,15 @@ import { i18n, translationErrorMessagesKeys, } from '../utils/errorMessages'; -import { findProjectById } from '../repositories/projectRepository'; +import { + findProjectById, + findProjectIdBySlug, +} from '../repositories/projectRepository'; import { convertExponentialNumber } from '../utils/utils'; import { fetchGivHistoricPrice } from './givPriceService'; import { findDonationById, + findRelevantDonations, findStableCoinDonationsWithoutPrice, } from '../repositories/donationRepository'; import { @@ -578,3 +582,43 @@ export const insertDonationsFromQfRoundHistory = async (): Promise => { } await updateUserTotalDonated(user.id); }; + +export async function getDonationToGivethWithDonationBoxMetrics( + startDate: Date, + endDate: Date, +) { + const givethProject = await findProjectIdBySlug('giveth'); + if (givethProject === null) { + throw new Error('giveth project not found!'); + } + + const { donationsToGiveth, pairedDonations } = await findRelevantDonations( + startDate, + endDate, + givethProject.id, + ); + const totalDonationsToGiveth = donationsToGiveth.length; + const totalUsdValueToGiveth = donationsToGiveth.reduce( + (sum, donation) => sum + (donation.valueUsd || 0), + 0, + ); + + const donationPercentages = donationsToGiveth.map((donation, index) => { + const pairedDonation = pairedDonations[index]; + const totalValue = + (donation.valueUsd || 0) + (pairedDonation.valueUsd || 0); + return totalValue > 0 ? (donation.valueUsd || 0) / totalValue : 0; + }); + + const averagePercentageToGiveth = + (donationPercentages.length > 0 + ? donationPercentages.reduce((sum, percentage) => sum + percentage, 0) / + donationPercentages.length + : 0) * 100; + + return { + totalDonationsToGiveth, + totalUsdValueToGiveth, + averagePercentageToGiveth, + }; +} diff --git a/src/utils/validators/graphqlQueryValidators.ts b/src/utils/validators/graphqlQueryValidators.ts index 6b265f28b..80e5d4444 100644 --- a/src/utils/validators/graphqlQueryValidators.ts +++ b/src/utils/validators/graphqlQueryValidators.ts @@ -120,6 +120,8 @@ export const createDonationQueryValidator = Joi.object({ referrerId: Joi.string().allow(null, ''), safeTransactionId: Joi.string().allow(null, ''), chainType: Joi.string().required(), + useDonationBox: Joi.boolean(), + relevantDonationTxHash: Joi.string().allow(null, ''), }); export const createDraftDonationQueryValidator = Joi.object({ @@ -145,6 +147,8 @@ export const createDraftDonationQueryValidator = Joi.object({ referrerId: Joi.string().allow(null, ''), safeTransactionId: Joi.string().allow(null, ''), chainType: Joi.string().required(), + useDonationBox: Joi.boolean(), + relevantDonationTxHash: Joi.string().allow(null, ''), }); export const createDraftRecurringDonationQueryValidator = Joi.object({ diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index eed85dfca..ebba6b4ee 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2404,3 +2404,19 @@ export const fetchRecurringDonationStatsQuery = ` } } `; + +export const fetchDonationMetricsQuery = ` + query ( + $startDate: String! + $endDate: String! + ) { + donationMetrics( + startDate: $startDate + endDate: $endDate + ) { + totalDonationsToGiveth + totalUsdValueToGiveth + averagePercentageToGiveth + } + } +`; diff --git a/test/testUtils.ts b/test/testUtils.ts index 531b9931a..098866f3c 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -36,6 +36,7 @@ import { ChainType } from '../src/types/network'; import { RecurringDonation } from '../src/entities/recurringDonation'; import { AnchorContractAddress } from '../src/entities/anchorContractAddress'; import { findProjectById } from '../src/repositories/projectRepository'; +import { ProjectAddress } from '../src/entities/projectAddress'; // eslint-disable-next-line @typescript-eslint/no-var-requires const moment = require('moment'); @@ -297,8 +298,8 @@ export const saveProjectDirectlyToDb = async ( )`); return project; }; -export const createProjectData = (): CreateProjectData => { - const title = String(new Date().getTime()); +export const createProjectData = (name?: string): CreateProjectData => { + const title = name ? name : String(new Date().getTime()); const walletAddress = generateRandomEtheriumAddress(); return { // title: `test project`, @@ -323,6 +324,25 @@ export const createProjectData = (): CreateProjectData => { projectUpdateCreationDate: new Date(), }; }; + +export const deleteProjectDirectlyFromDb = async ( + projectId: number, +): Promise => { + // Find and delete related project addresses + const projectAddresses = await ProjectAddress.find({ where: { projectId } }); + await ProjectAddress.remove(projectAddresses); + + // Find and delete related project updates + const projectUpdates = await ProjectUpdate.find({ where: { projectId } }); + await ProjectUpdate.remove(projectUpdates); + + // Delete the project + const project = await Project.findOne({ where: { id: projectId } }); + if (project) { + await Project.remove(project); + } +}; + export const createDonationData = (params?: { status?: string; createdAt?: Date; @@ -1919,6 +1939,8 @@ export interface CreateDonationData { qfRoundId?: number; tokenAddress?: string; qfRoundUserScore?: number; + useDonationBox?: boolean; + relevantDonationTxHash?: string; } export interface CategoryData {