diff --git a/config/test.env b/config/test.env index 0a330de92..c7d13625a 100644 --- a/config/test.env +++ b/config/test.env @@ -240,3 +240,6 @@ QACC_DONATION_TOKEN_DECIMALS=18 QACC_DONATION_TOKEN_NAME=Polygon Ecosystem Token QACC_DONATION_TOKEN_SYMBOL=POL + +GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE=50 +GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE=15 \ No newline at end of file diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index ab8b6c978..f40efde62 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -18,6 +18,7 @@ import { import { acceptedTermsOfService, batchMintingEligibleUsers, + batchMintingEligibleUsersV2, checkUserPrivadoVerifiedState, refreshUserScores, updateUser, @@ -32,6 +33,11 @@ import { updateUserTotalDonated } from '../services/userService'; import { getUserEmailConfirmationFields } from '../repositories/userRepository'; import { UserEmailVerification } from '../entities/userEmailVerification'; import { PrivadoAdapter } from '../adapters/privado/privadoAdapter'; +import { UserKycType } from './userResolver'; +import { + GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE, + GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE, +} from '../constants/gitcoin'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); @@ -58,6 +64,10 @@ describe( 'batchMintingEligibleUsers() test cases', batchMintingEligibleUsersTestCases, ); +describe( + 'batchMintingEligibleV2Users() test cases', + batchMintingEligibleUsersV2TestCases, +); // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); @@ -1179,37 +1189,85 @@ function batchMintingEligibleUsersTestCases() { // clear all users not empty accepted terms of service await User.delete({ acceptedToS: true }); }); + it('should return users who have accepted terms of service and privado verified', async () => { + const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + acceptedToS: true, + // 2 days ago + acceptedToSDate: new Date(Date.now() - DAY * 3), + }); + + const user2 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + acceptedToS: true, + // yesterday + acceptedToSDate: new Date(Date.now() - DAY * 2), + }); + + const user3 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + acceptedToS: true, + // yesterday + acceptedToSDate: new Date(Date.now() - DAY), + }); - it('should return empty array if there is no user to mint', async () => { const result = await axios.post(graphqlUrl, { query: batchMintingEligibleUsers, }); - assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, []); + assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, [ + user1.walletAddress, + user2.walletAddress, + user3.walletAddress, + ]); + }); +} + +function batchMintingEligibleUsersV2TestCases() { + const DAY = 86400000; + beforeEach(async () => { + // clear all users not empty accepted terms of service + await User.delete({ acceptedToS: true }); + }); + + it('should return empty array if there is no user to mint', async () => { + const result = await axios.post(graphqlUrl, { + query: batchMintingEligibleUsersV2, + }); + + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, []); }); - it('should return users who have accepted terms of service and privado verified', async () => { + it('should return users who have accepted terms of service and has valid kyc status', async () => { const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], acceptedToS: true, // 2 days ago - acceptedToSDate: new Date(Date.now() - DAY * 2), + acceptedToSDate: new Date(Date.now() - DAY * 3), }); const user2 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { - privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE + 0.001, + acceptedToS: true, + // yesterday + acceptedToSDate: new Date(Date.now() - DAY * 2), + }); + + const user3 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE + 0.001, acceptedToS: true, // yesterday acceptedToSDate: new Date(Date.now() - DAY), }); const result = await axios.post(graphqlUrl, { - query: batchMintingEligibleUsers, + query: batchMintingEligibleUsersV2, }); - assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, [ - user1.walletAddress, - user2.walletAddress, + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, [ + { address: user1.walletAddress, kycType: UserKycType.zkId }, + { address: user2.walletAddress, kycType: UserKycType.GTCPass }, + { address: user3.walletAddress, kycType: UserKycType.GTCPass }, ]); }); @@ -1225,15 +1283,15 @@ function batchMintingEligibleUsersTestCases() { }); const result = await axios.post(graphqlUrl, { - query: batchMintingEligibleUsers, + query: batchMintingEligibleUsersV2, }); - assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, [ - user.walletAddress, + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, [ + { address: user.walletAddress, kycType: UserKycType.zkId }, ]); }); - it('should not return users who have accepted terms of service but have not privado verified', async () => { + it('should not return users who have accepted terms of service but have not valid kyc status', async () => { const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], acceptedToS: true, @@ -1241,16 +1299,19 @@ function batchMintingEligibleUsersTestCases() { }); await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId + 1], + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE - 0.001, + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE - 0.001, acceptedToS: true, acceptedToSDate: new Date(Date.now() - DAY), }); const result = await axios.post(graphqlUrl, { - query: batchMintingEligibleUsers, + query: batchMintingEligibleUsersV2, }); - assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, [ - user1.walletAddress, + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, [ + { address: user1.walletAddress, kycType: UserKycType.zkId }, ]); }); @@ -1262,40 +1323,93 @@ function batchMintingEligibleUsersTestCases() { }); const user2 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { - privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE + 0.001, acceptedToS: true, acceptedToSDate: new Date(Date.now() - DAY * 2), }); const user3 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { - privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE + 0.001, acceptedToS: true, acceptedToSDate: new Date(Date.now() - DAY), }); let result = await axios.post(graphqlUrl, { - query: batchMintingEligibleUsers, + query: batchMintingEligibleUsersV2, variables: { limit: 2, skip: 0, }, }); - assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, [ - user1.walletAddress, - user2.walletAddress, + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, [ + { address: user1.walletAddress, kycType: UserKycType.zkId }, + { address: user2.walletAddress, kycType: UserKycType.GTCPass }, ]); result = await axios.post(graphqlUrl, { - query: batchMintingEligibleUsers, + query: batchMintingEligibleUsersV2, variables: { limit: 2, skip: 2, }, }); - assert.deepEqual(result.data.data.batchMintingEligibleUsers.users, [ - user3.walletAddress, + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, [ + { address: user3.walletAddress, kycType: UserKycType.GTCPass }, + ]); + }); + + it('should prioritize zkId over GTCPass', async () => { + const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE + 0.001, + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE + 0.001, + acceptedToS: true, + acceptedToSDate: new Date(Date.now() - DAY * 4), + }); + + const user2 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE - 0.001, + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE + 0.001, + acceptedToS: true, + acceptedToSDate: new Date(Date.now() - DAY * 3), + }); + + const user3 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE + 0.001, + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE - 0.001, + acceptedToS: true, + acceptedToSDate: new Date(Date.now() - DAY * 2), + }); + + const user4 = await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId + 1], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE + 0.001, + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE + 0.001, + acceptedToS: true, + acceptedToSDate: new Date(Date.now() - DAY), + }); + + await saveUserDirectlyToDb(generateRandomEtheriumAddress(), { + privadoVerifiedRequestIds: [PrivadoAdapter.privadoRequestId + 1], + analysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE - 0.001, + passportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE - 0.001, + acceptedToS: true, + acceptedToSDate: new Date(Date.now() - DAY), + }); + + const result = await axios.post(graphqlUrl, { + query: batchMintingEligibleUsersV2, + }); + + assert.deepEqual(result.data.data.batchMintingEligibleUsersV2.users, [ + { address: user1.walletAddress, kycType: UserKycType.zkId }, + { address: user2.walletAddress, kycType: UserKycType.zkId }, + { address: user3.walletAddress, kycType: UserKycType.zkId }, + { address: user4.walletAddress, kycType: UserKycType.GTCPass }, ]); }); } diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 9ad339cc5..f3779148a 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -6,9 +6,10 @@ import { Mutation, ObjectType, Query, + registerEnumType, Resolver, } from 'type-graphql'; -import { Repository } from 'typeorm'; +import { Brackets, Repository } from 'typeorm'; import moment from 'moment'; import { User } from '../entities/user'; @@ -36,6 +37,10 @@ import { addressHasDonated } from '../repositories/donationRepository'; // import { getOrttoPersonAttributes } from '../adapters/notifications/NotificationCenterAdapter'; import { retrieveActiveQfRoundUserMBDScore } from '../repositories/qfRoundRepository'; import { PrivadoAdapter } from '../adapters/privado/privadoAdapter'; +import { + GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE, + GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE, +} from '../constants/gitcoin'; @ObjectType() class UserRelatedAddressResponse { @@ -46,6 +51,25 @@ class UserRelatedAddressResponse { hasDonated: boolean; } +export enum UserKycType { + zkId = 'zkId', + GTCPass = 'GTCPass', +} + +registerEnumType(UserKycType, { + name: 'UserKycType', + description: 'User KYC type either Privado zk ID or Gitcoin Passport', +}); + +@ObjectType() +class EligibleUser { + @Field(_type => String, { nullable: false }) + address: string; + + @Field(_type => UserKycType, { nullable: false }) + kycType: UserKycType; +} + @ObjectType() class BatchMintingEligibleUserResponse { @Field(_addresses => [String], { nullable: false }) @@ -57,6 +81,17 @@ class BatchMintingEligibleUserResponse { @Field(_offset => Number, { nullable: false }) skip: number; } +@ObjectType() +class BatchMintingEligibleUserV2Response { + @Field(_addresses => [EligibleUser], { nullable: false }) + users: EligibleUser[]; + + @Field(_total => Number, { nullable: false }) + total: number; + + @Field(_offset => Number, { nullable: false }) + skip: number; +} // eslint-disable-next-line unused-imports/no-unused-imports @Resolver(_of => User) @@ -155,29 +190,19 @@ export class UserResolver { @Arg('skip', _type => Int, { nullable: true }) skip: number = 0, @Arg('filterAddress', { nullable: true }) filterAddress: string, ) { - if (filterAddress) { - const query = User.createQueryBuilder('user').where( - `LOWER("walletAddress") = :walletAddress`, - { - walletAddress: filterAddress.toLowerCase(), - }, - ); - - const userExists = await query.getExists(); - - return { - users: userExists ? [filterAddress] : [], - total: userExists ? 1 : 0, - skip: 0, - }; - } - - const response = await User.createQueryBuilder('user') + let query = User.createQueryBuilder('user') .select('user.walletAddress') .where('user.acceptedToS = true') .andWhere(':privadoRequestId = ANY (user.privadoVerifiedRequestIds)', { privadoRequestId: PrivadoAdapter.privadoRequestId, - }) + }); + if (filterAddress) { + query = query.andWhere(`LOWER("walletAddress") = :walletAddress`, { + walletAddress: filterAddress.toLowerCase(), + }); + } + + const response = await query .orderBy('user.acceptedToSDate', 'ASC') .take(limit) .skip(skip) @@ -190,6 +215,61 @@ export class UserResolver { }; } + @Query(_returns => BatchMintingEligibleUserV2Response) + async batchMintingEligibleUsersV2( + @Arg('limit', _type => Int, { nullable: true }) limit: number = 1000, + @Arg('skip', _type => Int, { nullable: true }) skip: number = 0, + @Arg('filterAddress', { nullable: true }) filterAddress: string, + ) { + let query = User.createQueryBuilder('user') + .select('user.walletAddress', 'address') + .addSelect( + 'CASE WHEN :privadoRequestId = ANY (user.privadoVerifiedRequestIds) THEN :zkId ELSE :GTCPass END', + 'kycType', + ) + .setParameters({ + privadoRequestId: PrivadoAdapter.privadoRequestId, + zkId: UserKycType.zkId, + GTCPass: UserKycType.GTCPass, + }) + .where('user.acceptedToS = true') + .andWhere( + new Brackets(qb => { + qb.where('user.analysisScore >= :minAnalysisScore', { + minAnalysisScore: GITCOIN_PASSPORT_MIN_VALID_ANALYSIS_SCORE, + }) + .orWhere('user.passportScore >= :minPassportScore', { + minPassportScore: GITCOIN_PASSPORT_MIN_VALID_SCORER_SCORE, + }) + .orWhere( + ':privadoRequestId = ANY (user.privadoVerifiedRequestIds)', + { + privadoRequestId: PrivadoAdapter.privadoRequestId, + }, + ); + }), + ); + + if (filterAddress) { + query = query.andWhere(`LOWER("walletAddress") = :walletAddress`, { + walletAddress: filterAddress.toLowerCase(), + }); + } + + const count = await query.getCount(); + const response = await query + .orderBy('user.acceptedToSDate', 'ASC') + .take(limit) + .skip(skip) + .getRawMany(); + + return { + users: response, + total: count, + skip, + }; + } + @Mutation(_returns => Boolean) async updateUser( @Arg('fullName', { nullable: true }) fullName: string, diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index ef9a5f732..e5092380b 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2121,7 +2121,20 @@ export const acceptedTermsOfService = ` export const batchMintingEligibleUsers = ` query ( $limit: Int, $skip: Int, $filterAddress: String) { batchMintingEligibleUsers(limit: $limit, skip: $skip, filterAddress: $filterAddress) { - users + users + total + skip + } + } +`; + +export const batchMintingEligibleUsersV2 = ` + query ( $limit: Int, $skip: Int, $filterAddress: String) { + batchMintingEligibleUsersV2(limit: $limit, skip: $skip, filterAddress: $filterAddress) { + users { + address + kycType + } total skip }