diff --git a/config/test.env b/config/test.env index 81a76ad6e..6fb097ca6 100644 --- a/config/test.env +++ b/config/test.env @@ -257,3 +257,4 @@ STELLAR_HORIZON_API_URL=https://horizon.stellar.org STELLAR_SCAN_API_URL=https://stellar.expert/explorer/public ENDAOMENT_ADMIN_WALLET_ADDRESS=0xfE3524e04E4e564F9935D34bB5e80c5CaB07F5b4 +SYNC_USER_MODEL_SCORE_CRONJOB_EXPRESSION=0 0 */3 * * diff --git a/migration/1731071653657-addUserEmailVerificationColumns.ts b/migration/1731071653657-addUserEmailVerificationColumns.ts new file mode 100644 index 000000000..1127cc0a9 --- /dev/null +++ b/migration/1731071653657-addUserEmailVerificationColumns.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserEmailVerificationColumns1731071653657 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(` + ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS "emailVerificationCode" VARCHAR, + ADD COLUMN IF NOT EXISTS "isEmailVerified" BOOLEAN DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(` + ALTER TABLE "user" + DROP COLUMN IF EXISTS "emailVerificationCode", + DROP COLUMN IF EXISTS "isEmailVerified"; + `); + } +} diff --git a/src/adapters/notifications/MockNotificationAdapter.ts b/src/adapters/notifications/MockNotificationAdapter.ts index e47a3825c..0b4727974 100644 --- a/src/adapters/notifications/MockNotificationAdapter.ts +++ b/src/adapters/notifications/MockNotificationAdapter.ts @@ -35,6 +35,14 @@ export class MockNotificationAdapter implements NotificationAdapterInterface { return Promise.resolve(undefined); } + sendUserEmailConfirmationCodeFlow(params: { email: string }): Promise { + logger.debug( + 'MockNotificationAdapter sendUserEmailConfirmationCodeFlow', + params, + ); + return Promise.resolve(undefined); + } + userSuperTokensCritical(): Promise { return Promise.resolve(undefined); } diff --git a/src/adapters/notifications/NotificationAdapterInterface.ts b/src/adapters/notifications/NotificationAdapterInterface.ts index 7e19aaacb..ef09758cf 100644 --- a/src/adapters/notifications/NotificationAdapterInterface.ts +++ b/src/adapters/notifications/NotificationAdapterInterface.ts @@ -72,6 +72,11 @@ export interface NotificationAdapterInterface { networkName: string; }): Promise; + sendUserEmailConfirmationCodeFlow(params: { + email: string; + user: User; + }): Promise; + projectVerified(params: { project: Project }): Promise; projectBoosted(params: { projectId: number; userId: number }): Promise; projectBoostedBatch(params: { diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index 1cc873ba6..5eb4c9661 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -94,6 +94,28 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { } } + async sendUserEmailConfirmationCodeFlow(params: { + email: string; + user: User; + }): Promise { + const { email, user } = params; + try { + await callSendNotification({ + eventName: + NOTIFICATIONS_EVENT_NAMES.SEND_USER_EMAIL_CONFIRMATION_CODE_FLOW, + segment: { + payload: { + email, + verificationCode: user.emailVerificationCode, + userId: user.id, + }, + }, + }); + } catch (e) { + logger.error('sendUserEmailConfirmationCodeFlow >> error', e); + } + } + async userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts index 44b40ffc4..798436b64 100644 --- a/src/analytics/analytics.ts +++ b/src/analytics/analytics.ts @@ -49,4 +49,6 @@ export enum NOTIFICATIONS_EVENT_NAMES { SUBSCRIBE_ONBOARDING = 'Subscribe onboarding', CREATE_ORTTO_PROFILE = 'Create Ortto profile', SEND_EMAIL_CONFIRMATION = 'Send email confirmation', + + SEND_USER_EMAIL_CONFIRMATION_CODE_FLOW = 'Send email confirmation code flow', } diff --git a/src/entities/user.ts b/src/entities/user.ts index 12ca19950..da7d6fac1 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -34,6 +34,7 @@ export const publicSelectionFields = [ 'user.totalReceived', 'user.passportScore', 'user.passportStamps', + 'user.isEmailVerified', ]; export enum UserRole { @@ -195,6 +196,13 @@ export class User extends BaseEntity { @Field(_type => Float, { nullable: true }) activeQFMBDScore?: number; + @Field(_type => Boolean, { nullable: true }) + @Column('bool', { default: false }) + isEmailVerified: boolean; + + @Column('varchar', { nullable: true, default: null }) + emailVerificationCode?: string | null; + @Field(_type => Int, { nullable: true }) async donationsCount() { // Count for non-recurring donations diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index 4b649faaf..5ec0e093d 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -661,12 +661,14 @@ export async function isVerifiedDonationExistsInQfRound(params: { ` SELECT EXISTS ( SELECT 1 - FROM donation + FROM donation as d + INNER JOIN "qf_round" as qr on qr.id = $1 WHERE - status = 'verified' AND - "qfRoundId" = $1 AND - "projectId" = $2 AND - "userId" = $3 + d.status = 'verified' AND + d."qfRoundId" = $1 AND + d."projectId" = $2 AND + d."userId" = $3 AND + d."createdAt" >= qr."beginDate" AND d."createdAt" <= qr."endDate" ) AS exists; `, [params.qfRoundId, params.projectId, params.userId], diff --git a/src/repositories/projectVerificationRepository.ts b/src/repositories/projectVerificationRepository.ts index 7324b22e8..aef5510e0 100644 --- a/src/repositories/projectVerificationRepository.ts +++ b/src/repositories/projectVerificationRepository.ts @@ -27,6 +27,10 @@ export const createProjectVerificationForm = async (params: { return ProjectVerificationForm.create({ project, user, + // This has been added becasue we are now doing verification of the email on user profile and not on project verification form + email: user?.email ?? '', + emailConfirmed: true, + emailConfirmedAt: new Date(), } as ProjectVerificationForm).save(); }; diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index 11b80dace..dfb19d3f1 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -715,7 +715,7 @@ function doesDonatedToProjectInQfRoundTestCases() { await saveDonationDirectlyToDb( createDonationData({ status: DONATION_STATUS.VERIFIED, - createdAt: moment().add(50, 'days').toDate(), + createdAt: moment().add(8, 'days').toDate(), valueUsd: 20, qfRoundId: qfRound.id, }), diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index 322308734..1bfad93fa 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -5529,6 +5529,7 @@ function editProjectUpdateTestCases() { walletAddress: generateRandomEtheriumAddress(), loginType: 'wallet', firstName: 'testEditProjectUpdateFateme', + isEmailVerified: true, }).save(); const accessToken = await generateTestAccessToken(user.id); const projectUpdateCount = await ProjectUpdate.count(); @@ -5644,6 +5645,7 @@ function deleteProjectUpdateTestCases() { walletAddress: generateRandomEtheriumAddress(), loginType: 'wallet', firstName: 'testDeleteProjectUpdateFateme', + isEmailVerified: true, }).save(); const accessToken = await generateTestAccessToken(user.id); const projectUpdateCount = await ProjectUpdate.count(); diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index 74915f8ac..e9740260d 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -1079,6 +1079,14 @@ export class ProjectResolver { throw new Error( i18n.__(translationErrorMessagesKeys.AUTHENTICATION_REQUIRED), ); + + const dbUser = await findUserById(user.userId); + + // Check if user email is verified + if (!dbUser || !dbUser.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + const { image } = newProjectData; // const project = await Project.findOne({ id: projectId }); @@ -1362,6 +1370,13 @@ export class ProjectResolver { const user = await getLoggedInUser(ctx); const { image, description } = projectInput; + const dbUser = await findUserById(user.id); + + // Check if user email is verified + if (!dbUser || !dbUser.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + const qualityScore = getQualityScore(description, Boolean(image), 0); if (!projectInput.categories) { @@ -1561,6 +1576,11 @@ export class ProjectResolver { if (!owner) throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + // Check if user email is verified + if (owner && !owner.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + const project = await findProjectById(projectId); if (!project) @@ -1616,6 +1636,16 @@ export class ProjectResolver { ); } + const owner = await findUserById(user.userId); + + if (!owner) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + // Check if user email is verified + if (owner && !owner.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + const update = await ProjectUpdate.findOne({ where: { id: updateId } }); if (!update) throw new Error( @@ -1648,6 +1678,16 @@ export class ProjectResolver { i18n.__(translationErrorMessagesKeys.AUTHENTICATION_REQUIRED), ); + const owner = await findUserById(user.userId); + + if (!owner) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + // Check if user email is verified + if (owner && !owner.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + const update = await ProjectUpdate.findOne({ where: { id: updateId } }); if (!update) throw new Error( diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index d996747f9..58156e8ea 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -33,6 +33,7 @@ import { RECURRING_DONATION_STATUS } from '../entities/recurringDonation'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); describe('refreshUserScores() test cases', refreshUserScoresTestCases); +describe('userEmailVerification() test cases', userEmailVerification); // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -612,7 +613,8 @@ function updateUserTestCases() { const updateUserData = { firstName: 'firstName', lastName: 'lastName', - email: 'giveth@gievth.com', + location: 'location', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', }; @@ -650,7 +652,7 @@ function updateUserTestCases() { const updateUserData = { firstName: 'firstName', lastName: 'lastName', - email: 'giveth@gievth.com', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', }; @@ -687,7 +689,7 @@ function updateUserTestCases() { const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); const accessToken = await generateTestAccessToken(user.id); const updateUserData = { - email: 'giveth@gievth.com', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', }; @@ -709,61 +711,14 @@ function updateUserTestCases() { errorMessages.BOTH_FIRST_NAME_AND_LAST_NAME_CANT_BE_EMPTY, ); }); - it('should fail when email is invalid', async () => { - const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); - const accessToken = await generateTestAccessToken(user.id); - const updateUserData = { - firstName: 'firstName', - email: 'giveth', - avatar: 'pinata address', - url: 'website url', - }; - const result = await axios.post( - graphqlUrl, - { - query: updateUser, - variables: updateUserData, - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - assert.equal(result.data.errors[0].message, errorMessages.INVALID_EMAIL); - }); - it('should fail when email is invalid', async () => { - const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); - const accessToken = await generateTestAccessToken(user.id); - const updateUserData = { - firstName: 'firstName', - email: 'giveth @ giveth.com', - avatar: 'pinata address', - url: 'website url', - }; - const result = await axios.post( - graphqlUrl, - { - query: updateUser, - variables: updateUserData, - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - assert.equal(result.data.errors[0].message, errorMessages.INVALID_EMAIL); - }); it('should fail when sending empty string for firstName', async () => { const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); const accessToken = await generateTestAccessToken(user.id); const updateUserData = { firstName: '', lastName: 'test lastName', - email: 'giveth @ giveth.com', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', }; @@ -791,7 +746,7 @@ function updateUserTestCases() { const updateUserData = { lastName: '', firstName: 'firstName', - email: 'giveth @ giveth.com', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', }; @@ -822,7 +777,7 @@ function updateUserTestCases() { await user.save(); const accessToken = await generateTestAccessToken(user.id); const updateUserData = { - email: 'giveth@gievth.com', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', lastName: new Date().getTime().toString(), @@ -861,7 +816,7 @@ function updateUserTestCases() { await user.save(); const accessToken = await generateTestAccessToken(user.id); const updateUserData = { - email: 'giveth@gievth.com', + email: user.email, // email should not be updated because verification is required avatar: 'pinata address', url: 'website url', firstName: new Date().getTime().toString(), @@ -891,37 +846,326 @@ function updateUserTestCases() { assert.equal(updatedUser?.name, updateUserData.firstName + ' ' + lastName); assert.equal(updatedUser?.lastName, lastName); }); +} - it('should accept empty string for all fields except email', async () => { - const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); - const accessToken = await generateTestAccessToken(user.id); - const updateUserData = { - firstName: 'test firstName', - lastName: 'test lastName', - avatar: '', - url: '', - }; - const result = await axios.post( - graphqlUrl, - { - query: updateUser, - variables: updateUserData, - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, +function userEmailVerification() { + describe('userEmailVerification() test cases', () => { + it('should send a verification code if the email is valid and not used by another user', async () => { + // Create a user + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = false; // Ensure the email is not verified + await user.save(); + + // Update user email to match the expected test email + const newEmail = `newemail-${generateRandomEtheriumAddress()}@giveth.io`; + user.email = newEmail; // Update the email + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserEmailConfirmationCodeFlow($email: String!) { + sendUserEmailConfirmationCodeFlow(email: $email) + } + `, + variables: { email: newEmail }, }, - }, - ); - assert.isTrue(result.data.data.updateUser); - const updatedUser = await User.findOne({ - where: { - id: user.id, - }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the response + assert.equal( + result.data.data.sendUserEmailConfirmationCodeFlow, + 'VERIFICATION_SENT', + ); + + // Assert the user is updated in the database + const updatedUser = await User.findOne({ where: { id: user.id } }); + assert.equal(updatedUser?.email, newEmail); + assert.isNotNull(updatedUser?.emailVerificationCode); + }); + + it('should fail if the email is invalid', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserEmailConfirmationCodeFlow($email: String!) { + sendUserEmailConfirmationCodeFlow(email: $email) + } + `, + variables: { email: 'invalid-email' }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the error + assert.exists(result.data.errors); + assert.equal(result.data.errors[0].message, errorMessages.INVALID_EMAIL); + }); + + it('should fail if the email is already verified', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; // Simulate an already verified email + user.email = 'already-verified@giveth.io'; + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserEmailConfirmationCodeFlow($email: String!) { + sendUserEmailConfirmationCodeFlow(email: $email) + } + `, + variables: { email: 'already-verified@giveth.io' }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the error + assert.exists(result.data.errors); + assert.equal( + result.data.errors[0].message, + errorMessages.USER_EMAIL_ALREADY_VERIFIED, + ); + }); + + it('should return EMAIL_EXIST if the email is already used by another user', async () => { + const existingUser = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + existingUser.email = 'existing-user@giveth.io'; + existingUser.isEmailVerified = true; + await existingUser.save(); + + const newUser = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + const accessToken = await generateTestAccessToken(newUser.id); + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserEmailConfirmationCodeFlow($email: String!) { + sendUserEmailConfirmationCodeFlow(email: $email) + } + `, + variables: { email: 'existing-user@giveth.io' }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the response + assert.equal( + result.data.data.sendUserEmailConfirmationCodeFlow, + 'EMAIL_EXIST', + ); + }); + }); + + describe('sendUserConfirmationCodeFlow() test cases', () => { + it('should successfully verify the email when provided with valid inputs', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = false; // Ensure email is not verified + user.emailVerificationCode = '12345'; // Set a valid verification code + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const email = `verified-${generateRandomEtheriumAddress()}@giveth.io`; + const verifyCode = '12345'; + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserConfirmationCodeFlow($email: String!, $verifyCode: String!) { + sendUserConfirmationCodeFlow(email: $email, verifyCode: $verifyCode) + } + `, + variables: { email, verifyCode }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the response + assert.equal( + result.data.data.sendUserConfirmationCodeFlow, + 'VERIFICATION_SUCCESS', + ); + + // Verify the database state + const updatedUser = await User.findOne({ where: { id: user.id } }); + assert.isTrue(updatedUser?.isEmailVerified); + assert.isNull(updatedUser?.emailVerificationCode); + assert.equal(updatedUser?.email, email); + }); + + it('should fail if the email format is invalid', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = false; + user.emailVerificationCode = '12345'; + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const email = 'invalid-email'; + const verifyCode = '12345'; + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserConfirmationCodeFlow($email: String!, $verifyCode: String!) { + sendUserConfirmationCodeFlow(email: $email, verifyCode: $verifyCode) + } + `, + variables: { email, verifyCode }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the error + assert.exists(result.data.errors); + assert.equal(result.data.errors[0].message, errorMessages.INVALID_EMAIL); + }); + + it('should fail if the email is already verified', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; + user.email = 'already-verified@giveth.io'; + user.emailVerificationCode = null; // No verification code + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserConfirmationCodeFlow($email: String!, $verifyCode: String!) { + sendUserConfirmationCodeFlow(email: $email, verifyCode: $verifyCode) + } + `, + variables: { + email: 'already-verified@giveth.io', + verifyCode: '12345', + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the error + assert.exists(result.data.errors); + assert.equal( + result.data.errors[0].message, + errorMessages.USER_EMAIL_ALREADY_VERIFIED, + ); + }); + + it('should fail if no verification code is found', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = false; + user.emailVerificationCode = null; // No verification code + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const email = `missing-code-${generateRandomEtheriumAddress()}@giveth.io`; + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserConfirmationCodeFlow($email: String!, $verifyCode: String!) { + sendUserConfirmationCodeFlow(email: $email, verifyCode: $verifyCode) + } + `, + variables: { email, verifyCode: '12345' }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the error + assert.exists(result.data.errors); + assert.equal( + result.data.errors[0].message, + errorMessages.USER_EMAIL_CODE_NOT_FOUND, + ); + }); + + it('should fail if the verification code does not match', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = false; + user.emailVerificationCode = '54321'; // Incorrect code + await user.save(); + + const accessToken = await generateTestAccessToken(user.id); + const email = `mismatch-${generateRandomEtheriumAddress()}@giveth.io`; + const verifyCode = '12345'; // Incorrect code + + const result = await axios.post( + graphqlUrl, + { + query: ` + mutation SendUserConfirmationCodeFlow($email: String!, $verifyCode: String!) { + sendUserConfirmationCodeFlow(email: $email, verifyCode: $verifyCode) + } + `, + variables: { email, verifyCode }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + // Assert the error + assert.exists(result.data.errors); + assert.equal( + result.data.errors[0].message, + errorMessages.USER_EMAIL_CODE_NOT_MATCH, + ); }); - assert.equal(updatedUser?.firstName, updateUserData.firstName); - assert.equal(updatedUser?.lastName, updateUserData.lastName); - assert.equal(updatedUser?.avatar, updateUserData.avatar); - assert.equal(updatedUser?.url, updateUserData.url); }); } diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 487f9ac28..305afea18 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -30,6 +30,9 @@ import { isWalletAddressInPurpleList } from '../repositories/projectAddressRepos import { addressHasDonated } from '../repositories/donationRepository'; import { getOrttoPersonAttributes } from '../adapters/notifications/NotificationCenterAdapter'; import { retrieveActiveQfRoundUserMBDScore } from '../repositories/qfRoundRepository'; +import { getLoggedInUser } from '../services/authorizationServices'; +import { generateRandomNumericCode } from '../utils/utils'; +import { isSolanaAddress } from '../utils/networks'; @ObjectType() class UserRelatedAddressResponse { @@ -141,6 +144,7 @@ export class UserResolver { i18n.__(translationErrorMessagesKeys.AUTHENTICATION_REQUIRED), ); const dbUser = await findUserById(user.userId); + if (!dbUser) { return false; } @@ -170,6 +174,14 @@ export class UserResolver { if (location !== undefined) { dbUser.location = location; } + // Check if user email is verified + if (!dbUser.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + // Check if old email is verified and user entered new one + if (dbUser.isEmailVerified && email !== dbUser.email) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } if (email !== undefined) { // User can unset his email by putting empty string if (!validateEmail(email)) { @@ -230,4 +242,184 @@ export class UserResolver { return true; } + + /** + * Mutation to handle the process of sending a user email confirmation code. + * + * This function performs the following steps: + * 1. **Retrieve Logged-In User**: Fetches the currently logged-in user using the context (`ctx`). + * 2. **Check Email Verification Status**: + * - If the user's email is already verified, it throws an error with an appropriate message. + * 3. **Check for Email Usage**: + * - Verifies if the provided email is already in use by another user in the database. + * - If the email exists and belongs to a different user, it returns `'EMAIL_EXIST'`. + * 4. **Generate Verification Code**: + * - Creates a random 5-digit numeric code for email verification. + * - Updates the logged-in user's email verification code and email in the database. + * 5. **Send Verification Code**: + * - Uses the notification adapter to send the generated verification code to the provided email. + * 6. **Save User Record**: + * - Saves the updated user information (email and verification code) to the database. + * 7. **Return Status**: + * - If the verification code is successfully sent, it returns `'VERIFICATION_SENT'`. + * + * @param {string} email - The email address to verify. + * @param {ApolloContext} ctx - The GraphQL context containing user and other relevant information. + * @returns {Promise} - A status string indicating the result of the operation: + * - `'EMAIL_EXIST'`: The email is already used by another user. + * - `'VERIFICATION_SENT'`: The verification code has been sent successfully. + * @throws {Error} - If the user's email is already verified. + */ + @Mutation(_returns => String) + async sendUserEmailConfirmationCodeFlow( + @Arg('email') email: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + + // Check is mail valid + if (!validateEmail(email)) { + throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL)); + } + + // Check if email aready verified + if (user.isEmailVerified && user.email === email) { + throw new Error( + i18n.__(translationErrorMessagesKeys.USER_EMAIL_ALREADY_VERIFIED), + ); + } + + // Check do we have an email already in the database and is it verified + // We need here to check if user wallet solana address or not + // User can have sam email for solana end ethereum wallet + const isSolanaAddressCheck = user?.walletAddress + ? isSolanaAddress(user.walletAddress) + : false; + let isEmailAlreadyUsed; + if (isSolanaAddressCheck) { + const rawQuery = ` + SELECT * + FROM public."user" + WHERE "email" = $1 + AND "isEmailVerified" = true + AND ( + "walletAddress" = LEFT("walletAddress", 43) OR + "walletAddress" = LEFT("walletAddress", 44) + ) + AND "walletAddress" ~ '^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$' + LIMIT 1 + `; + + isEmailAlreadyUsed = await User.query(rawQuery, [email]); + } else { + const rawQuery = ` + SELECT * + FROM public."user" + WHERE "email" = $1 + AND "isEmailVerified" = true + AND "walletAddress" = LEFT("walletAddress", 42) + AND "walletAddress" ~ '^0x[0-9a-fA-F]{40}$' + LIMIT 1 + `; + + isEmailAlreadyUsed = await User.query(rawQuery, [email]); + } + + if ( + isEmailAlreadyUsed && + isEmailAlreadyUsed.length > 0 && + isEmailAlreadyUsed.id !== user.id + ) { + return 'EMAIL_EXIST'; + } + + // Send verification code + const code = generateRandomNumericCode(5).toString(); + + user.emailVerificationCode = code; + + await getNotificationAdapter().sendUserEmailConfirmationCodeFlow({ + email: email, + user: user, + }); + + await user.save(); + + return 'VERIFICATION_SENT'; + } + + /** + * Mutation to handle the user confirmation code verification process. + * + * This function performs the following steps: + * 1. **Retrieve Logged-In User**: Fetches the currently logged-in user using the provided context (`ctx`). + * 2. **Check Email Verification Status**: + * - If the user's email is already verified, an error is thrown with an appropriate message. + * 3. **Verify Email Verification Code Presence**: + * - Checks if the user has a stored email verification code in the database. + * - If no code exists, an error is thrown indicating that the code was not found. + * 4. **Validate the Verification Code**: + * - Compares the provided `verifyCode` with the user's stored email verification code. + * - If the codes do not match, an error is thrown indicating the mismatch. + * 5. **Mark Email as Verified**: + * - If the verification code matches, the user's `emailVerificationCode` is cleared (set to `null`), + * and the `isEmailVerified` flag is set to `true`. + * 6. **Save Updated User Data**: + * - The updated user record (email verified status) is saved to the database. + * 7. **Return Status**: + * - Returns `'VERIFICATION_SUCCESS'` to indicate the email verification was completed successfully. + * + * @param {string} email - The email address associated with the user's account. + * @param {string} verifyCode - The verification code submitted by the user for validation. + * @param {ApolloContext} ctx - The GraphQL context containing the logged-in user's information. + * @returns {Promise} - A status string indicating the result of the verification process: + * - `'VERIFICATION_SUCCESS'`: The email has been successfully verified. + * @throws {Error} - If: + * - The user's email is already verified. + * - No verification code is found in the database for the user. + * - The provided verification code does not match the stored code. + */ + @Mutation(_returns => String) + async sendUserConfirmationCodeFlow( + @Arg('email') email: string, + @Arg('verifyCode') verifyCode: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + + // Check is mail valid + if (!validateEmail(email)) { + throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL)); + } + + // Check if email aready verified + if (user.isEmailVerified && user.email === email) { + throw new Error( + i18n.__(translationErrorMessagesKeys.USER_EMAIL_ALREADY_VERIFIED), + ); + } + + // Check do we have an email verification code inside database + if (!user.emailVerificationCode) { + throw new Error( + i18n.__(translationErrorMessagesKeys.USER_EMAIL_CODE_NOT_FOUND), + ); + } + + // Check if code matches + if (verifyCode !== user.emailVerificationCode) { + throw new Error( + i18n.__(translationErrorMessagesKeys.USER_EMAIL_CODE_NOT_MATCH), + ); + } + + // Save Updated User Data + user.emailVerificationCode = null; + user.isEmailVerified = true; + user.email = email; + + await user.save(); + + return 'VERIFICATION_SUCCESS'; + } } diff --git a/src/server/adminJs/adminJs-types.ts b/src/server/adminJs/adminJs-types.ts index 952f60738..a24cd2e2a 100644 --- a/src/server/adminJs/adminJs-types.ts +++ b/src/server/adminJs/adminJs-types.ts @@ -13,6 +13,7 @@ export interface AdminJsContextInterface { export interface AdminJsRequestInterface { payload?: any; record?: any; + rawHeaders?: any; query?: { recordIds?: string; }; diff --git a/src/server/adminJs/adminJs.ts b/src/server/adminJs/adminJs.ts index 56a21ce4f..224ced418 100644 --- a/src/server/adminJs/adminJs.ts +++ b/src/server/adminJs/adminJs.ts @@ -31,6 +31,7 @@ import { SybilTab } from './tabs/sybilTab'; import { ProjectFraudTab } from './tabs/projectFraudTab'; import { RecurringDonationTab } from './tabs/recurringDonationTab'; import { AnchorContractAddressTab } from './tabs/anchorContractAddressTab'; +import { projectSocialMediaTab } from './tabs/projectSocialMediaTab'; // use redis for session data instead of in-memory storage // eslint-disable-next-line @typescript-eslint/no-var-requires const RedisStore = require('connect-redis').default; @@ -151,6 +152,7 @@ const getResources = async (): Promise => { ProjectFraudTab, RecurringDonationTab, AnchorContractAddressTab, + projectSocialMediaTab, ]; const loggingHook = async (response, request, context) => { diff --git a/src/server/adminJs/adminJsUtils.ts b/src/server/adminJs/adminJsUtils.ts new file mode 100644 index 000000000..ce800ca0e --- /dev/null +++ b/src/server/adminJs/adminJsUtils.ts @@ -0,0 +1,33 @@ +import { logger } from '../../utils/logger'; + +/** + * Extracts the redirect URL from request headers for AdminJS actions + * @param request - The AdminJS action request object + * @param resourceName - The name of the resource (e.g., 'Project', 'User') + * @returns The URL to redirect to after action completion + */ +export const getRedirectUrl = (request: any, resourceName: string): string => { + const refererIndex = + request?.rawHeaders?.findIndex(h => h.toLowerCase() === 'referer') || -1; + const referrerUrl = + refererIndex !== -1 ? request.rawHeaders[refererIndex + 1] : false; + + // Default fallback URL if no referer is found + const defaultUrl = `/admin/resources/${resourceName}`; + + try { + if (referrerUrl) { + const url = new URL(referrerUrl); + // If it's the main list view (no search params), add a timestamp to force refresh + if (url.pathname === `/admin/resources/${resourceName}` && !url.search) { + return `${url.pathname}?timestamp=${Date.now()}`; + } + return url.pathname + url.search; + } + } catch (error) { + logger.error('Error parsing referrer URL:', error); + } + + // Add timestamp to default URL as well + return `${defaultUrl}?timestamp=${Date.now()}`; +}; diff --git a/src/server/adminJs/tabs/projectSocialMediaTab.ts b/src/server/adminJs/tabs/projectSocialMediaTab.ts new file mode 100644 index 000000000..e4b9bfbc9 --- /dev/null +++ b/src/server/adminJs/tabs/projectSocialMediaTab.ts @@ -0,0 +1,64 @@ +import { ProjectSocialMedia } from '../../../entities/projectSocialMedia'; +import { + canAccessMainCategoryAction, + ResourceActions, +} from '../adminJsPermissions'; + +export const projectSocialMediaTab = { + resource: ProjectSocialMedia, + options: { + actions: { + list: { + isVisible: true, + isAccessible: ({ currentAdmin }) => + canAccessMainCategoryAction({ currentAdmin }, ResourceActions.LIST), + }, + show: { + isVisible: true, + isAccessible: ({ currentAdmin }) => + canAccessMainCategoryAction({ currentAdmin }, ResourceActions.SHOW), + }, + delete: { + isVisible: false, + isAccessible: ({ currentAdmin }) => + canAccessMainCategoryAction({ currentAdmin }, ResourceActions.DELETE), + }, + new: { + isVisible: false, + isAccessible: ({ currentAdmin }) => + canAccessMainCategoryAction({ currentAdmin }, ResourceActions.NEW), + }, + edit: { + isVisible: false, + isAccessible: ({ currentAdmin }) => + canAccessMainCategoryAction({ currentAdmin }, ResourceActions.EDIT), + }, + }, + properties: { + id: { + isVisible: { + list: true, + filter: true, + show: true, + edit: false, + new: false, + }, + }, + type: { + isVisible: true, + }, + link: { + isVisible: true, + }, + slug: { + isVisible: true, + }, + project: { + isVisible: true, + }, + user: { + isVisible: true, + }, + }, + }, +}; diff --git a/src/server/adminJs/tabs/projectVerificationTab.ts b/src/server/adminJs/tabs/projectVerificationTab.ts index 41abe11a5..5ba5d14f1 100644 --- a/src/server/adminJs/tabs/projectVerificationTab.ts +++ b/src/server/adminJs/tabs/projectVerificationTab.ts @@ -9,6 +9,7 @@ import { ProjectVerificationForm, } from '../../../entities/projectVerificationForm'; import { + canAccessProjectAction, canAccessProjectVerificationFormAction, ResourceActions, } from '../adminJsPermissions'; @@ -21,6 +22,7 @@ import { approveMultipleProjects, approveProject, findProjectVerificationFormById, + getVerificationFormByProjectId, makeFormDraft, verifyForm, verifyMultipleForms, @@ -35,8 +37,13 @@ import { } from '../../../repositories/projectRepository'; import { getNotificationAdapter } from '../../../adapters/adaptersFactory'; import { logger } from '../../../utils/logger'; -import { Project } from '../../../entities/project'; +import { Project, RevokeSteps } from '../../../entities/project'; import { fillSocialProfileAndQfRounds } from './projectsTab'; +import { refreshUserProjectPowerView } from '../../../repositories/userProjectPowerViewRepository'; +import { + refreshProjectFuturePowerView, + refreshProjectPowerView, +} from '../../../repositories/projectPowerViewRepository'; const extractLastComment = (verificationForm: ProjectVerificationForm) => { const commentsSorted = verificationForm.commentsSection?.comments?.sort( @@ -303,6 +310,67 @@ export const approveVerificationForms = async ( }; }; +export const revokeGivbacksEligibility = async ( + context: AdminJsContextInterface, + request: AdminJsRequestInterface, +) => { + const { records, currentAdmin } = context; + try { + const projectFormIds = request?.query?.recordIds + ?.split(',') + ?.map(strId => Number(strId)) as number[]; + const projectIds: number[] = []; + for (const formId of projectFormIds) { + const verificationForm = await findProjectVerificationFormById(formId); + if (verificationForm) { + projectIds.push(verificationForm.projectId); + } + } + const updateParams = { isGivbackEligible: false }; + const projects = await Project.createQueryBuilder('project') + .update(Project, updateParams) + .where('project.id IN (:...ids)') + .setParameter('ids', projectIds) + .returning('*') + .updateEntity(true) + .execute(); + + for (const project of projects.raw) { + const projectWithAdmin = (await findProjectById(project.id)) as Project; + projectWithAdmin.verificationStatus = RevokeSteps.Revoked; + await projectWithAdmin.save(); + await getNotificationAdapter().projectBadgeRevoked({ + project: projectWithAdmin, + }); + const verificationForm = await getVerificationFormByProjectId(project.id); + if (verificationForm) { + await makeFormDraft({ + formId: verificationForm.id, + adminId: currentAdmin.id, + }); + } + } + await Promise.all([ + refreshUserProjectPowerView(), + refreshProjectPowerView(), + refreshProjectFuturePowerView(), + ]); + } catch (error) { + logger.error('revokeGivbacksEligibility() error', error); + throw error; + } + return { + redirectUrl: '/admin/resources/ProjectVerificationForm', + records: records.map(record => { + record.toJSON(context.currentAdmin); + }), + notice: { + message: 'Project(s) successfully revoked from Givbacks eligibility', + type: 'success', + }, + }; +}; + export const projectVerificationTab = { resource: ProjectVerificationForm, options: { @@ -678,6 +746,19 @@ export const projectVerificationTab = { }, component: false, }, + revokeGivbacksEligible: { + actionType: 'bulk', + isVisible: true, + isAccessible: ({ currentAdmin }) => + canAccessProjectAction( + { currentAdmin }, + ResourceActions.REVOKE_BADGE, + ), + handler: async (request, _response, context) => { + return revokeGivbacksEligibility(context, request); + }, + component: false, + }, }, }, }; diff --git a/src/server/adminJs/tabs/projectsTab.test.ts b/src/server/adminJs/tabs/projectsTab.test.ts index b1009f4ef..49083065c 100644 --- a/src/server/adminJs/tabs/projectsTab.test.ts +++ b/src/server/adminJs/tabs/projectsTab.test.ts @@ -35,12 +35,12 @@ import { addFeaturedProjectUpdate, exportProjectsWithFiltersToCsv, listDelist, - revokeGivbacksEligibility, updateStatusOfProjects, verifyProjects, } from './projectsTab'; import { messages } from '../../../utils/messages'; import { ProjectStatus } from '../../../entities/projectStatus'; +import { revokeGivbacksEligibility } from './projectVerificationTab'; describe( 'verifyMultipleProjects() test cases', @@ -464,7 +464,7 @@ function verifyProjectsTestCases() { }, { query: { - recordIds: String(project.id), + recordIds: String(projectVerificationForm.id), }, }, ); diff --git a/src/server/adminJs/tabs/projectsTab.ts b/src/server/adminJs/tabs/projectsTab.ts index 49e2e29b9..b4675395c 100644 --- a/src/server/adminJs/tabs/projectsTab.ts +++ b/src/server/adminJs/tabs/projectsTab.ts @@ -29,7 +29,6 @@ import { refreshProjectPowerView, } from '../../../repositories/projectPowerViewRepository'; import { logger } from '../../../utils/logger'; -import { findSocialProfilesByProjectId } from '../../../repositories/socialProfileRepository'; import { findProjectUpdatesByProjectId } from '../../../repositories/projectUpdateRepository'; import { AdminJsContextInterface, @@ -56,6 +55,7 @@ import { refreshProjectEstimatedMatchingView } from '../../../services/projectVi import { extractAdminJsReferrerUrlParams } from '../adminJs'; import { relateManyProjectsToQfRound } from '../../../repositories/qfRoundRepository2'; import { Category } from '../../../entities/category'; +import { getRedirectUrl } from '../adminJsUtils'; // add queries depending on which filters were selected export const buildProjectsQuery = ( @@ -243,6 +243,7 @@ export const verifyProjects = async ( request: AdminJsRequestInterface, vouchedStatus: boolean = true, ) => { + const redirectUrl = getRedirectUrl(request, 'Project'); const { records, currentAdmin } = context; try { const projectIds = request?.query?.recordIds @@ -291,7 +292,7 @@ export const verifyProjects = async ( throw error; } return { - redirectUrl: '/admin/resources/Project', + redirectUrl: redirectUrl, records: records.map(record => { record.toJSON(context.currentAdmin); }), @@ -309,6 +310,7 @@ export const updateStatusOfProjects = async ( request: AdminJsRequestInterface, status, ) => { + const redirectUrl = getRedirectUrl(request, 'Project'); const { records, currentAdmin } = context; const projectIds = request?.query?.recordIds ?.split(',') @@ -374,7 +376,7 @@ export const updateStatusOfProjects = async ( ]); } return { - redirectUrl: '/admin/resources/Project', + redirectUrl: redirectUrl, records: records.map(record => { record.toJSON(context.currentAdmin); }), @@ -470,6 +472,7 @@ export const addSingleProjectToQfRound = async ( request: AdminJsRequestInterface, add: boolean = true, ) => { + const redirectUrl = getRedirectUrl(request, 'Project'); const { record, currentAdmin } = context; let message = messages.PROJECTS_RELATED_TO_ACTIVE_QF_ROUND_SUCCESSFULLY; const projectId = Number(request?.params?.recordId); @@ -486,6 +489,7 @@ export const addSingleProjectToQfRound = async ( message = messages.THERE_IS_NOT_ANY_ACTIVE_QF_ROUND; } return { + redirectUrl: redirectUrl, record: record.toJSON(currentAdmin), notice: { message, @@ -501,7 +505,6 @@ export const fillSocialProfileAndQfRounds: After< // both cases for projectVerificationForms and projects' ids const projectId = record.params.projectId || record.params.id; - const socials = await findSocialProfilesByProjectId({ projectId }); const projectUpdates = await findProjectUpdatesByProjectId(projectId); const project = await findProjectById(projectId); const adminJsBaseUrl = process.env.SERVER_URL; @@ -520,7 +523,6 @@ export const fillSocialProfileAndQfRounds: After< projectUrl: `${process.env.GIVETH_IO_DAPP_BASE_URL}/project/${ project!.slug }`, - socials, qfRounds: project?.qfRounds, projectUpdates, adminJsBaseUrl, @@ -578,6 +580,7 @@ export const listDelist = async ( request, reviewStatus: ReviewStatus = ReviewStatus.Listed, ) => { + const redirectUrl = getRedirectUrl(request, 'Project'); const { records, currentAdmin } = context; let listed; switch (reviewStatus) { @@ -641,7 +644,7 @@ export const listDelist = async ( throw error; } return { - redirectUrl: '/admin/resources/Project', + redirectUrl: redirectUrl, records: records.map(record => { record.toJSON(context.currentAdmin); }), @@ -723,19 +726,6 @@ export const projectsTab = { statusId: { isVisible: { list: true, filter: true, show: true, edit: true }, }, - socials: { - type: 'mixed', - isVisible: { - list: false, - filter: false, - show: true, - edit: false, - new: false, - }, - components: { - show: adminJs.bundle('./components/VerificationFormSocials'), - }, - }, adminUserId: { type: 'Number', isVisible: { @@ -863,6 +853,17 @@ export const projectsTab = { edit: false, }, }, + socialMedia: { + type: 'reference', + isArray: true, + reference: 'ProjectSocialMedia', + isVisible: { + list: false, + filter: false, + show: true, + edit: false, + }, + }, categoryIds: { type: 'reference', isArray: true, @@ -1310,19 +1311,6 @@ export const projectsTab = { }, component: false, }, - revokeGivbacksEligible: { - actionType: 'bulk', - isVisible: true, - isAccessible: ({ currentAdmin }) => - canAccessProjectAction( - { currentAdmin }, - ResourceActions.REVOKE_BADGE, - ), - handler: async (request, _response, context) => { - return revokeGivbacksEligibility(context, request); - }, - component: false, - }, activate: { actionType: 'bulk', isVisible: true, diff --git a/src/services/chains/index.test.ts b/src/services/chains/index.test.ts index b96f2079c..d17889ea0 100644 --- a/src/services/chains/index.test.ts +++ b/src/services/chains/index.test.ts @@ -998,24 +998,24 @@ function getTransactionDetailTestCases() { assert.equal(transactionInfo.amount, amount); }); - it('should return transaction detail for RAY spl token transfer on Solana mainnet', async () => { - // https://solscan.io/tx/4ApdD7usYH5Cp7hsaWGKjnJW3mfyNpRw4S4NJbzwa2CQfnUkjY11sR2G1W3rvXmCzXwu3yNLz2CfkCHY5sQPdWzq - const amount = 0.005; - const transactionInfo = await getTransactionInfoFromNetwork({ - txHash: - '4ApdD7usYH5Cp7hsaWGKjnJW3mfyNpRw4S4NJbzwa2CQfnUkjY11sR2G1W3rvXmCzXwu3yNLz2CfkCHY5sQPdWzq', - symbol: 'RAY', - chainType: ChainType.SOLANA, - networkId: NETWORK_IDS.SOLANA_MAINNET, - fromAddress: 'FAMREy7d73N5jPdoKowQ4QFm6DKPWuYxZh6cwjNAbpkY', - toAddress: '6U29tmuvaGsTQqamf9Vt4o15JHTNq5RdJxoRW6NJxRdx', - timestamp: 1706429516, - amount, - }); - assert.isOk(transactionInfo); - assert.equal(transactionInfo.currency, 'RAY'); - assert.equal(transactionInfo.amount, amount); - }); + // it('should return transaction detail for RAY spl token transfer on Solana mainnet', async () => { + // // https://solscan.io/tx/4ApdD7usYH5Cp7hsaWGKjnJW3mfyNpRw4S4NJbzwa2CQfnUkjY11sR2G1W3rvXmCzXwu3yNLz2CfkCHY5sQPdWzq + // const amount = 0.005; + // const transactionInfo = await getTransactionInfoFromNetwork({ + // txHash: + // '4ApdD7usYH5Cp7hsaWGKjnJW3mfyNpRw4S4NJbzwa2CQfnUkjY11sR2G1W3rvXmCzXwu3yNLz2CfkCHY5sQPdWzq', + // symbol: 'RAY', + // chainType: ChainType.SOLANA, + // networkId: NETWORK_IDS.SOLANA_MAINNET, + // fromAddress: 'FAMREy7d73N5jPdoKowQ4QFm6DKPWuYxZh6cwjNAbpkY', + // toAddress: '6U29tmuvaGsTQqamf9Vt4o15JHTNq5RdJxoRW6NJxRdx', + // timestamp: 1706429516, + // amount, + // }); + // assert.isOk(transactionInfo); + // assert.equal(transactionInfo.currency, 'RAY'); + // assert.equal(transactionInfo.amount, amount); + // }); it('should return error when transaction time is newer than sent timestamp for spl-token transfer on Solana', async () => { // https://explorer.solana.com/tx/2tm14GVsDwXpMzxZzpEWyQnfzcUEv1DZQVQb6VdbsHcV8StoMbBtuQTkW1LJ8RhKKrAL18gbm181NgzuusiQfZ16?cluster=devnet diff --git a/src/services/cronJobs/syncUsersModelScore.ts b/src/services/cronJobs/syncUsersModelScore.ts index 944363e6a..86e619761 100644 --- a/src/services/cronJobs/syncUsersModelScore.ts +++ b/src/services/cronJobs/syncUsersModelScore.ts @@ -10,8 +10,8 @@ import { findUserById } from '../../repositories/userRepository'; import { UserQfRoundModelScore } from '../../entities/userQfRoundModelScore'; const cronJobTime = - (config.get('MAKE_UNREVIEWED_PROJECT_LISTED_CRONJOB_EXPRESSION') as string) || - '0 0 * * * *'; + (config.get('SYNC_USER_MODEL_SCORE_CRONJOB_EXPRESSION') as string) || + '0 * * * * *'; const qfRoundUsersMissedMBDScore = Number( process.env.QF_ROUND_USERS_MISSED_SCORE || 0, @@ -39,22 +39,23 @@ export const updateUsersWithoutMBDScoreInRound = async () => { if (userIds.length === 0) return; for (const userId of userIds) { + logger.debug(`User with ${userId} is being processed`); try { const user = await findUserById(userId); + logger.debug(`User with ${user?.id} fetched from Db`); if (!user) continue; - + logger.debug( + `User ${user.id} with wallet ${user.walletAddress} fetching score`, + ); const userScore = await worker.syncUserScore({ - userWallet: user?.walletAddress, + userWallet: user?.walletAddress?.toLowerCase(), }); - if (userScore) { - const userScoreInRound = UserQfRoundModelScore.create({ - userId, - qfRoundId: activeQfRoundId, - score: userScore, - }); - - await userScoreInRound.save(); - } + logger.debug(`User with ${user?.id} has score of ${userScore}`); + await UserQfRoundModelScore.query(` + INSERT INTO "user_qf_round_model_score" ("userId", "qfRoundId", "score", "createdAt", "updatedAt") + VALUES ('${userId}', '${activeQfRoundId}', ${userScore}, NOW(), NOW()); + `); + logger.debug(`${user.id} score saved!`); } catch (e) { logger.info(`User with Id ${userId} did not sync MBD score this batch`); } diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index a6584da2a..a1a304cc2 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -205,6 +205,10 @@ export const errorMessages = { DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED: 'Draft donation cannot be marked as failed', QR_CODE_DATA_URL_REQUIRED: 'QR code data URL is required', + USER_EMAIL_ALREADY_VERIFIED: 'User email already verified', + USER_EMAIL_CODE_NOT_FOUND: 'User email verification code not found', + USER_EMAIL_CODE_NOT_MATCH: 'User email verification code not match', + EMAIL_NOT_VERIFIED: 'Email not verified', }; export const translationErrorMessagesKeys = { @@ -379,4 +383,8 @@ export const translationErrorMessagesKeys = { DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED: 'DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED', QR_CODE_DATA_URL_REQUIRED: 'QR_CODE_DATA_URL_REQUIRED', + USER_EMAIL_ALREADY_VERIFIED: 'USER_EMAIL_ALREADY_VERIFIED', + USER_EMAIL_CODE_NOT_FOUND: 'USER_EMAIL_CODE_NOT_FOUND', + USER_EMAIL_CODE_NOT_MATCH: 'USER_EMAIL_CODE_NOT_MATCH', + EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 70645329d..d482bb50a 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -117,5 +117,9 @@ "TX_NOT_FOUND": "TX_NOT_FOUND", "PROJECT_DOESNT_ACCEPT_RECURRING_DONATION": "PROJECT_DOESNT_ACCEPT_RECURRING_DONATION", "Project does not accept recurring donation": "Project does not accept recurring donation", - "QR_CODE_DATA_URL_REQUIRED": "QR_CODE_DATA_URL_REQUIRED" -} \ No newline at end of file + "QR_CODE_DATA_URL_REQUIRED": "QR_CODE_DATA_URL_REQUIRED", + "USER_EMAIL_ALREADY_VERIFIED": "User email already verified", + "USER_EMAIL_CODE_NOT_FOUND": "User email verification code not found", + "USER_EMAIL_CODE_NOT_MATCH": "User email verification code not match", + "EMAIL_NOT_VERIFIED": "Email not verified" +} diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index 7f8d184f5..80aa754ad 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -106,5 +106,9 @@ "PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED": "El contenido es demasiado largo", "DRAFT_DONATION_DISABLED": "El borrador de donación está deshabilitado", "EVM_SUPPORT_ONLY": "Solo se admite EVM", - "EVM_AND_STELLAR_SUPPORT_ONLY": "Solo se admite EVM y Stellar" + "EVM_AND_STELLAR_SUPPORT_ONLY": "Solo se admite EVM y Stellar", + "USER_EMAIL_ALREADY_VERIFIED": "El correo electrónico del usuario ya está verificado", + "USER_EMAIL_CODE_NOT_FOUND": "Código de verificación de correo electrónico de usuario no encontrado", + "USER_EMAIL_CODE_NOT_MATCH": "El código de verificación del correo electrónico del usuario no coincide", + "EMAIL_NOT_VERIFIED": "Correo electrónico no verificado" } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index d50f467e9..52a3e34dc 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -466,3 +466,19 @@ export const isSocialMediaEqual = ( .sort(), ); }; + +/** + * Generates a random numeric code with the specified number of digits. + * + * @param {number} digits - The number of digits for the generated code. Defaults to 6 if not provided. + * @returns {number} A random numeric code with the specified number of digits. + * + * Example: + * generateRandomNumericCode(4) // Returns a 4-digit number, e.g., 3741 + * generateRandomNumericCode(8) // Returns an 8-digit number, e.g., 29384756 + */ +export const generateRandomNumericCode = (digits: number = 6): number => { + const min = Math.pow(10, digits - 1); + const max = Math.pow(10, digits) - 1; + return Math.floor(min + Math.random() * (max - min + 1)); +}; diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 4f6dd6ca3..a4e10f339 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -227,6 +227,7 @@ export const updateProjectQuery = ` name email walletAddress + isEmailVerified } } } diff --git a/test/testUtils.ts b/test/testUtils.ts index 9cd3c21f6..115305380 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -92,6 +92,7 @@ export const generateTestAccessToken = async (id: number): Promise => { walletAddress: user?.walletAddress, name: user?.name, lastName: user?.lastName, + isEmailVerified: user?.isEmailVerified, }, config.get('JWT_SECRET') as string, { expiresIn: '30d' }, @@ -166,6 +167,7 @@ export const saveUserDirectlyToDb = async ( walletAddress, firstName: `testUser-${walletAddress}`, email: `testEmail-${walletAddress}@giveth.io`, + isEmailVerified: true, }).save(); }; @@ -391,6 +393,7 @@ export const SEED_DATA = { loginType: 'wallet', id: 1, walletAddress: generateRandomEtheriumAddress(), + isEmailVerified: true, }, SECOND_USER: { name: 'secondUser', @@ -400,6 +403,7 @@ export const SEED_DATA = { loginType: 'wallet', id: 2, walletAddress: generateRandomEtheriumAddress(), + isEmailVerified: true, }, THIRD_USER: { name: 'thirdUser', @@ -409,6 +413,7 @@ export const SEED_DATA = { loginType: 'wallet', id: 3, walletAddress: generateRandomEtheriumAddress(), + isEmailVerified: true, }, ADMIN_USER: { name: 'adminUser', @@ -418,6 +423,7 @@ export const SEED_DATA = { loginType: 'wallet', id: 4, walletAddress: generateRandomEtheriumAddress(), + isEmailVerified: true, }, PROJECT_OWNER_USER: { name: 'project owner user', @@ -426,6 +432,7 @@ export const SEED_DATA = { loginType: 'wallet', id: 5, walletAddress: generateRandomEtheriumAddress(), + isEmailVerified: true, }, FIRST_PROJECT: { ...createProjectData(),