diff --git a/.github/workflows/master-pipeline.yml b/.github/workflows/master-pipeline.yml index f4a2b04ac..cdd4067e3 100644 --- a/.github/workflows/master-pipeline.yml +++ b/.github/workflows/master-pipeline.yml @@ -115,6 +115,8 @@ jobs: SOLANA_MAINNET_NODE_RPC_URL: ${{ secrets.SOLANA_MAINNET_NODE_RPC_URL }} MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} GIV_POWER_SUBGRAPH_URL: ${{ secrets.GIV_POWER_SUBGRAPH_URL }} + VERIFY_RIGHT_URL: ${{ secrets.VERIFY_RIGHT_URL }} + VERIFY_RIGHT_TOKEN: ${{ secrets.VERIFY_RIGHT_TOKEN }} publish: needs: test diff --git a/.github/workflows/run-tests-on-pr.yml.bck b/.github/workflows/run-tests-on-pr.yml.bck index c5fdd8d5a..c23bd1039 100644 --- a/.github/workflows/run-tests-on-pr.yml.bck +++ b/.github/workflows/run-tests-on-pr.yml.bck @@ -69,3 +69,5 @@ jobs: ARBITRUM_SCAN_API_KEY: ${{ secrets.ARBITRUM_SCAN_API_KEY }} ARBITRUM_SEPOLIA_SCAN_API_KEY: ${{ secrets.ARBITRUM_SEPOLIA_SCAN_API_KEY }} MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} + VERIFY_RIGHT_URL: ${{ secrets.VERIFY_RIGHT_URL }} + VERIFY_RIGHT_TOKEN: ${{ secrets.VERIFY_RIGHT_TOKEN }} diff --git a/.github/workflows/staging-pipeline.yml b/.github/workflows/staging-pipeline.yml index c35112702..6e80e8041 100644 --- a/.github/workflows/staging-pipeline.yml +++ b/.github/workflows/staging-pipeline.yml @@ -139,6 +139,8 @@ jobs: SOLANA_MAINNET_NODE_RPC_URL: ${{ secrets.SOLANA_MAINNET_NODE_RPC_URL }} MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} GIV_POWER_SUBGRAPH_URL: ${{ secrets.GIV_POWER_SUBGRAPH_URL }} + VERIFY_RIGHT_URL: ${{ secrets.VERIFY_RIGHT_URL }} + VERIFY_RIGHT_TOKEN: ${{ secrets.VERIFY_RIGHT_TOKEN }} publish: needs: test diff --git a/src/adapters/notifications/MockNotificationAdapter.ts b/src/adapters/notifications/MockNotificationAdapter.ts index 0b4727974..60ca785b6 100644 --- a/src/adapters/notifications/MockNotificationAdapter.ts +++ b/src/adapters/notifications/MockNotificationAdapter.ts @@ -11,9 +11,9 @@ import { logger } from '../../utils/logger'; import { RecurringDonation } from '../../entities/recurringDonation'; export class MockNotificationAdapter implements NotificationAdapterInterface { - async subscribeOnboarding(params: { email: string }): Promise { + async subscribeOnboarding(params: { email: string }): Promise { logger.debug('MockNotificationAdapter subscribeOnboarding', params); - return Promise.resolve(undefined); + return Promise.resolve(true); } async createOrttoProfile(params: User): Promise { diff --git a/src/adapters/notifications/NotificationAdapterInterface.ts b/src/adapters/notifications/NotificationAdapterInterface.ts index ef09758cf..46bfaeb95 100644 --- a/src/adapters/notifications/NotificationAdapterInterface.ts +++ b/src/adapters/notifications/NotificationAdapterInterface.ts @@ -34,7 +34,7 @@ export interface OrttoPerson { } export interface NotificationAdapterInterface { - subscribeOnboarding(params: { email: string }): Promise; + subscribeOnboarding(params: { email: string }): Promise; createOrttoProfile(params: User): Promise; diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index 0a0ad698e..0bf4b1ed1 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -19,6 +19,7 @@ import { findAllUsers, findUserById, findUsersWhoSupportProject, + isValidEmail, } from '../../repositories/userRepository'; import { buildProjectLink } from './NotificationCenterUtils'; import { buildTxLink } from '../../utils/networks'; @@ -62,18 +63,21 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { } } - async subscribeOnboarding(params: { email: string }): Promise { + async subscribeOnboarding(params: { email: string }): Promise { try { const { email } = params; - if (!email) return; + const isValid = await isValidEmail(email); + if (!email || !isValid) return false; await callSendNotification({ eventName: NOTIFICATIONS_EVENT_NAMES.SUBSCRIBE_ONBOARDING, segment: { payload: { email }, }, }); + return true; } catch (e) { logger.error('subscribeOnboarding >> error', e); + return false; } } diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index d53d30fd0..e2acbbf78 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -462,14 +462,25 @@ export const donorsCountPerDate = async ( export const newDonorsCount = async (fromDate: string, toDate: string) => { return Donation.createQueryBuilder('donation') - .select('donation.userId') - .addSelect('MIN(donation.createdAt)') - .groupBy('donation.userId') - .having('MIN(donation.createdAt) BETWEEN :fromDate AND :toDate', { + .select( + ` + CASE + WHEN donation."userId" IS NOT NULL THEN CONCAT('user_', donation."userId") + ELSE CONCAT('anon_', donation."fromWalletAddress") + END + `, + 'donor_identity', + ) + .addSelect('MIN(donation."createdAt")', 'firstDonationDate') + .where('"valueUsd" IS NOT NULL') + .andWhere( + '(donation."userId" IS NOT NULL OR donation."fromWalletAddress" IS NOT NULL)', + ) + .groupBy('donor_identity') + .having('MIN(donation."createdAt") BETWEEN :fromDate AND :toDate', { fromDate, toDate, }) - .groupBy('donation.userId') .getRawMany(); }; @@ -478,16 +489,31 @@ export const newDonorsDonationTotalUsd = async ( toDate: string, ) => { const result = await Donation.query( - `SELECT SUM(d."valueUsd") AS total_usd_value_of_first_donations -FROM ( - SELECT "userId", MIN("createdAt") AS firstDonationDate - FROM "donation" - GROUP BY "userId" -) AS first_donations -JOIN "donation" d ON first_donations."userId" = d."userId" AND first_donations.firstDonationDate = d."createdAt" -WHERE d."createdAt" BETWEEN $1 AND $2 - AND d."valueUsd" IS NOT NULL; -`, + ` + SELECT SUM(d."valueUsd") AS total_usd_value_of_first_donations + FROM ( + SELECT + CASE + WHEN "userId" IS NOT NULL THEN CONCAT('user_', "userId") + ELSE CONCAT('anon_', "fromWalletAddress") + END AS donor_identity, + MIN("createdAt") AS firstDonationDate + FROM "donation" + WHERE "valueUsd" IS NOT NULL + AND ("userId" IS NOT NULL OR "fromWalletAddress" IS NOT NULL) + GROUP BY donor_identity + ) AS first_donations + JOIN "donation" d + ON ( + CASE + WHEN d."userId" IS NOT NULL THEN CONCAT('user_', d."userId") + ELSE CONCAT('anon_', d."fromWalletAddress") + END + ) = first_donations.donor_identity + AND d."createdAt" = first_donations.firstDonationDate + WHERE d."createdAt" BETWEEN $1 AND $2 + AND d."valueUsd" IS NOT NULL; + `, [fromDate, toDate], ); return result[0]?.total_usd_value_of_first_donations || 0; diff --git a/src/repositories/userRepository.test.ts b/src/repositories/userRepository.test.ts index 585e4b4a5..c7d3bfbad 100644 --- a/src/repositories/userRepository.test.ts +++ b/src/repositories/userRepository.test.ts @@ -17,6 +17,7 @@ import { findUsersWhoDonatedToProjectExcludeWhoLiked, findUsersWhoLikedProjectExcludeProjectOwner, findUsersWhoSupportProject, + isValidEmail, } from './userRepository'; import { Reaction } from '../entities/reaction'; import { insertSinglePowerBoosting } from './powerBoostingRepository'; @@ -51,6 +52,8 @@ describe( findUsersWhoDonatedToProjectTestCases, ); +describe('isValidEmail test cases', isValidEmailTestCases); + function findUsersWhoDonatedToProjectTestCases() { it('should find wallet addresses of who donated to a project, exclude who liked', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); @@ -590,3 +593,17 @@ function findUsersWhoSupportProjectTestCases() { ); }); } + +function isValidEmailTestCases() { + it('should return true for valid email', async () => { + const email = `${new Date().getTime()}@giveth.io`; + const isValid = await isValidEmail(email); + assert.isOk(isValid); + }); + + it('should return false for invalid email', async () => { + const email = `${new Date().getTime()}@giveeeeth.io`; + const isValid = await isValidEmail(email); + assert.isNotOk(isValid); + }); +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 0f7ca150f..ef4f75d75 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -5,6 +5,7 @@ import { PowerBoosting } from '../entities/powerBoosting'; import { Project, ProjStatus, ReviewStatus } from '../entities/project'; import { isEvmAddress } from '../utils/networks'; import { retrieveActiveQfRoundUserMBDScore } from './qfRoundRepository'; +import { validateEmailWithExternalService } from '../utils/user'; export const findAdminUserByEmail = async ( email: string, @@ -201,3 +202,13 @@ export const findUsersWhoSupportProject = async ( } return users; }; + +/** + * Check if the email is valid + * + * @param email + * @returns true if the email is valid, false otherwise + */ +export const isValidEmail = async (email: string): Promise => { + return validateEmailWithExternalService(email); +}; diff --git a/src/resolvers/donationResolver.ts b/src/resolvers/donationResolver.ts index bb7207810..4d12144f6 100644 --- a/src/resolvers/donationResolver.ts +++ b/src/resolvers/donationResolver.ts @@ -253,9 +253,28 @@ export class DonationResolver { const query = this.donationRepository .createQueryBuilder('donation') .select('currency') - .addSelect('COUNT(DISTINCT "userId")', 'uniqueDonorCount') .addSelect( - 'COUNT(DISTINCT "userId") * 100.0 / SUM(COUNT(DISTINCT "userId")) OVER ()', + `COUNT(DISTINCT + CASE + WHEN donation."userId" IS NOT NULL THEN CONCAT('user_', donation."userId") + ELSE CONCAT('anon_', donation."fromWalletAddress") + END + )`, + 'uniqueDonorCount', + ) + .addSelect( + `COUNT(DISTINCT + CASE + WHEN donation."userId" IS NOT NULL THEN CONCAT('user_', donation."userId") + ELSE CONCAT('anon_', donation."fromWalletAddress") + END + ) * 100.0 / + NULLIF(SUM(COUNT(DISTINCT + CASE + WHEN donation."userId" IS NOT NULL THEN CONCAT('user_', donation."userId") + ELSE CONCAT('anon_', donation."fromWalletAddress") + END + )) OVER (), 0)`, 'currencyPercentage', ) .groupBy('currency') diff --git a/src/resolvers/onboardingFormResolver.ts b/src/resolvers/onboardingFormResolver.ts index c841b6dde..7540ce659 100644 --- a/src/resolvers/onboardingFormResolver.ts +++ b/src/resolvers/onboardingFormResolver.ts @@ -7,8 +7,10 @@ export class OnboardingFormResolver { @Query(_returns => Boolean) async subscribeOnboarding(@Arg('email') email: string): Promise { try { - await getNotificationAdapter().subscribeOnboarding({ email }); - return true; + const response = await getNotificationAdapter().subscribeOnboarding({ + email, + }); + return response; } catch (e) { logger.debug('subscribeOnboarding() error', e); return false; diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index b11490f9b..8d2fbf53e 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -30,12 +30,14 @@ import { updateUserTotalDonated } from '../services/userService'; import { addNewAnchorAddress } from '../repositories/anchorContractAddressRepository'; import { NETWORK_IDS } from '../provider'; import { RECURRING_DONATION_STATUS } from '../entities/recurringDonation'; +import { isValidEmail } from '../repositories/userRepository'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); describe('refreshUserScores() test cases', refreshUserScoresTestCases); describe('userEmailVerification() test cases', userEmailVerification); describe('allUsersBasicData() test cases', allUsersBasicData); +describe('isValidEmail() test cases', isValidEmailTestCases); // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -1250,3 +1252,19 @@ function allUsersBasicData() { }); }); } + +function isValidEmailTestCases() { + describe('isValidEmail() test cases', () => { + it('should return true for valid email', async () => { + const email = `${new Date().getTime()}@giveth.io`; + const isValid = await isValidEmail(email); + assert.isTrue(isValid); + }); + + it('should return false for invalid email', async () => { + const email = `${new Date().getTime()}@giveeeeth.io`; + const isValid = await isValidEmail(email); + assert.isNotOk(isValid); + }); + }); +} diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index c5e4b6860..94311de00 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -18,6 +18,7 @@ import { validateEmail } from '../utils/validators/commonValidators'; import { findUserById, findUserByWalletAddress, + isValidEmail, } from '../repositories/userRepository'; import { createNewAccountVerification } from '../repositories/accountVerificationRepository'; import { UserByAddressResponse } from './types/userResolver'; @@ -198,7 +199,7 @@ export class UserResolver { } if (email !== undefined) { // User can unset his email by putting empty string - if (!validateEmail(email)) { + if (!validateEmail(email) || !isValidEmail(email)) { throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL)); } dbUser.email = email; @@ -402,7 +403,7 @@ export class UserResolver { const user = await getLoggedInUser(ctx); // Check is mail valid - if (!validateEmail(email)) { + if (!validateEmail(email) || !isValidEmail(email)) { throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL)); } @@ -461,4 +462,9 @@ export class UserResolver { totalCount, }; } + + @Query(_returns => Boolean) + async validateEmail(@Arg('email') email: string): Promise { + return isValidEmail(email); + } } diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 4a1449c81..8878f7096 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -70,7 +70,6 @@ import { isTestEnv } from '../utils/utils'; import { refreshProjectEstimatedMatchingView } from '../services/projectViewsService'; import { runCheckAndUpdateEndaomentProject } from '../services/cronJobs/checkAndUpdateEndaomentProject'; import { runGenerateSitemapOnFrontend } from '../services/cronJobs/generateSitemapOnFrontend'; -import { runCheckPendingUserModelScoreCronjob } from '../services/cronJobs/syncUsersModelScore'; Resource.validate = validate; @@ -372,11 +371,6 @@ export async function bootstrap() { runCheckProjectVerificationStatus(); } - // If we need to deactivate the process use the env var NO MORE - // if (process.env.SYNC_USERS_MBD_SCORE_ACTIVE === 'true') { - runCheckPendingUserModelScoreCronjob(); - // } - // if (process.env.SITEMAP_CRON_SECRET !== '') { // runGenerateSitemapOnFrontend(); // } diff --git a/src/utils/user.ts b/src/utils/user.ts new file mode 100644 index 000000000..6c9432d15 --- /dev/null +++ b/src/utils/user.ts @@ -0,0 +1,60 @@ +import axios from 'axios'; +import { logger } from '../utils/logger'; + +const validateEmailWithRegex = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +/** + * Validate email with external service - VerifyRight + * + * @param email string + * @returns boolean + */ +export const validateEmailWithExternalService = async ( + email: string, +): Promise => { + try { + const verifyRightUrl = process.env.VERIFY_RIGHT_URL; + const verifyRightToken = process.env.VERIFY_RIGHT_TOKEN; + + if (!verifyRightUrl || !verifyRightToken) { + logger.error('VerifyRight configuration missing'); + return validateEmailWithRegex(email); + } + + const requestUrl = `${verifyRightUrl}/${email}?token=${verifyRightToken}`; + + const response = await axios.get(requestUrl, { + headers: { Accept: 'application/json' }, + }); + + if (response.data.status === false && response.data.error) { + logger.warn( + `VerifyRight API flagged email: ${email} - ${response.data.error.message}`, + ); + return false; + } + + return true; + } catch (error) { + if (error.response) { + if (error.response.status === 400) { + logger.warn( + `VerifyRight rejected email as invalid: ${email} - ${error.response.data?.error?.message || 'Invalid email format'}`, + ); + return false; + } + if (error.response.status === 502) { + logger.warn( + `VerifyRight rejected email as invalid: ${email} - ${error.response.data?.error?.message || 'Invalid email format'}`, + ); + return false; + } + } + + logger.error('VerifyRight validation service error:', error); + return validateEmailWithRegex(email); + } +};