diff --git a/config/test.env b/config/test.env index 8a3d288fa..6fb097ca6 100644 --- a/config/test.env +++ b/config/test.env @@ -257,4 +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 * * \ No newline at end of file +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/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index 74915f8ac..5025787be 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) { 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..67d78bd59 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -30,6 +30,8 @@ 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'; @ObjectType() class UserRelatedAddressResponse { @@ -141,6 +143,7 @@ export class UserResolver { i18n.__(translationErrorMessagesKeys.AUTHENTICATION_REQUIRED), ); const dbUser = await findUserById(user.userId); + if (!dbUser) { return false; } @@ -170,6 +173,14 @@ export class UserResolver { if (location !== undefined) { dbUser.location = location; } + // Check if user email is verified and it's not the first update + if (!dbUser.isEmailVerified) { + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_NOT_VERIFIED)); + } + // Check if old email is verified and user entered new one and it's not the first update + 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 +241,149 @@ 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 + const isEmailAlreadyUsed = await User.findOne({ + where: { email: email }, + }); + + if (isEmailAlreadyUsed && 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/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(),