From 3d751841e6ce86d1e7bfbdce1c96b6de0e211802 Mon Sep 17 00:00:00 2001 From: Reshzera Date: Mon, 12 Aug 2024 14:56:22 -0300 Subject: [PATCH 1/5] feat: add isEmailVerified flag on user --- .../1723123773224-add_is_email_verified.ts | 18 ++++++++++++++++++ src/entities/user.ts | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 migration/1723123773224-add_is_email_verified.ts diff --git a/migration/1723123773224-add_is_email_verified.ts b/migration/1723123773224-add_is_email_verified.ts new file mode 100644 index 000000000..e169a003b --- /dev/null +++ b/migration/1723123773224-add_is_email_verified.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddIsEmailVerified1723123773224 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'isEmailVerified', + type: 'boolean', + default: false, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('user', 'isEmailVerified'); + } +} diff --git a/src/entities/user.ts b/src/entities/user.ts index 12ca19950..27db40e4b 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -156,6 +156,10 @@ export class User extends BaseEntity { @Column('bool', { default: false }) segmentIdentified: boolean; + @Field(_type => Boolean, { nullable: true }) + @Column('bool', { default: false }) + isEmailVerified: boolean; + // Admin Reviewing Forms @Field(_type => [ProjectVerificationForm], { nullable: true }) @OneToMany( From 2a72388e49d780cfc84362d019721cc42ea8d7db Mon Sep 17 00:00:00 2001 From: Reshzera Date: Wed, 14 Aug 2024 13:48:41 -0300 Subject: [PATCH 2/5] fix: staging merge --- ...723581552603-add_verification_code_user.ts | 20 +++++++ .../notifications/MockNotificationAdapter.ts | 8 +++ .../NotificationAdapterInterface.ts | 5 ++ .../NotificationCenterAdapter.ts | 21 +++++++ src/analytics/analytics.ts | 1 + src/entities/user.ts | 3 + src/resolvers/userResolver.test.ts | 6 ++ src/resolvers/userResolver.ts | 57 +++++++++++++++++++ src/utils/errorMessages.ts | 4 ++ src/utils/locales/en.json | 4 +- src/utils/locales/es.json | 4 +- src/utils/utils.ts | 4 ++ 12 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 migration/1723581552603-add_verification_code_user.ts diff --git a/migration/1723581552603-add_verification_code_user.ts b/migration/1723581552603-add_verification_code_user.ts new file mode 100644 index 000000000..626db44e0 --- /dev/null +++ b/migration/1723581552603-add_verification_code_user.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddVerificationCodeUser1723581552603 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'verificationCode', + type: 'int', + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('user', 'verificationCode'); + } +} diff --git a/src/adapters/notifications/MockNotificationAdapter.ts b/src/adapters/notifications/MockNotificationAdapter.ts index e47a3825c..69d720e46 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); } + sendEmailConfirmationCodeFlow(params: { email: string }): Promise { + logger.debug( + 'MockNotificationAdapter sendEmailConfirmationCodeFlow', + 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..3d6b97f8e 100644 --- a/src/adapters/notifications/NotificationAdapterInterface.ts +++ b/src/adapters/notifications/NotificationAdapterInterface.ts @@ -63,6 +63,11 @@ export interface NotificationAdapterInterface { token: string; }): Promise; + sendEmailConfirmationCodeFlow(params: { + email: string; + user: User; + }): Promise; + userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index 1cc873ba6..2ee62fa4d 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -94,6 +94,27 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { } } + async sendEmailConfirmationCodeFlow(params: { + email: string; + user: User; + }): Promise { + const { email, user } = params; + try { + await callSendNotification({ + eventName: NOTIFICATIONS_EVENT_NAMES.SEND_EMAIL_CONFIRMATION_CODE_FLOW, + segment: { + payload: { + email, + verificationCode: user.verificationCode, + userId: user.id, + }, + }, + }); + } catch (e) { + logger.error('sendEmailConfirmationCodeFlow >> error', e); + } + } + async userSuperTokensCritical(params: { user: User; eventName: UserStreamBalanceWarning; diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts index 44b40ffc4..ae8c48fc5 100644 --- a/src/analytics/analytics.ts +++ b/src/analytics/analytics.ts @@ -49,4 +49,5 @@ export enum NOTIFICATIONS_EVENT_NAMES { SUBSCRIBE_ONBOARDING = 'Subscribe onboarding', CREATE_ORTTO_PROFILE = 'Create Ortto profile', SEND_EMAIL_CONFIRMATION = 'Send email confirmation', + SEND_EMAIL_CONFIRMATION_CODE_FLOW = 'Send email confirmation code flow', } diff --git a/src/entities/user.ts b/src/entities/user.ts index 27db40e4b..e1eff4c5f 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -160,6 +160,9 @@ export class User extends BaseEntity { @Column('bool', { default: false }) isEmailVerified: boolean; + @Column({ nullable: true, default: null }) + verificationCode?: number; + // Admin Reviewing Forms @Field(_type => [ProjectVerificationForm], { nullable: true }) @OneToMany( diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index d996747f9..920a9ea8d 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('confirmUserEmail() test cases', confirmUserEmailTestCases); // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -925,3 +926,8 @@ function updateUserTestCases() { assert.equal(updatedUser?.url, updateUserData.url); }); } +function confirmUserEmailTestCases(): void { + it('should confirm user email', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + }); +} diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 487f9ac28..a7d5e3151 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 { generateEmailVerificationCode } from '../utils/utils'; +import { getLoggedInUser } from '../services/authorizationServices'; @ObjectType() class UserRelatedAddressResponse { @@ -230,4 +232,59 @@ export class UserResolver { return true; } + + @Mutation(_returns => Boolean) + async confirmUserEmail( + @Arg('email') email: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (user.isEmailVerified) + throw new Error( + i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED), + ); + + const code = generateEmailVerificationCode(); + + user.verificationCode = code; + + await getNotificationAdapter().sendEmailConfirmationCodeFlow({ + email: email, + user: user, + }); + + await user.save(); + + return true; + } + + @Mutation(_returns => Boolean) + async verifyUserEmail( + @Arg('code') code: number, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (user.isEmailVerified) + throw new Error( + i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED), + ); + + if (user.verificationCode !== code) + throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL_CODE)); + + user.isEmailVerified = true; + user.verificationCode = undefined; + + await user.save(); + + return true; + } } diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index a6584da2a..996caf13e 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -205,6 +205,8 @@ 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', + EMAIL_ALREADY_VERIFIED: 'Email already verified', + INVALID_EMAIL_CODE: 'Invalid email code', }; export const translationErrorMessagesKeys = { @@ -379,4 +381,6 @@ 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', + EMAIL_ALREADY_VERIFIED: 'EMAIL_ALREADY_VERIFIED', + INVALID_EMAIL_CODE: 'INVALID_EMAIL_CODE', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 70645329d..079e1bbf1 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -117,5 +117,7 @@ "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" + "QR_CODE_DATA_URL_REQUIRED": "QR_CODE_DATA_URL_REQUIRED", + "EMAIL_ALREADY_VERIFIED": "Email already verified", + "INVALID_EMAIL_CODE": "Invalid email code" } \ No newline at end of file diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index 7f8d184f5..e844b6954 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -106,5 +106,7 @@ "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", + "EMAIL_ALREADY_VERIFIED": "El correo electrónico ya ha sido verificado", + "INVALID_EMAIL_CODE": "Código de correo electrónico no válido" } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index d50f467e9..f97cfbbd7 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -466,3 +466,7 @@ export const isSocialMediaEqual = ( .sort(), ); }; + +export const generateEmailVerificationCode = (): number => { + return Math.floor(100000 + Math.random() * 900000); +}; From 70fc8e2ad7ce233d808ed7ab652899ee97f223a5 Mon Sep 17 00:00:00 2001 From: Reshzera Date: Wed, 14 Aug 2024 15:44:51 -0300 Subject: [PATCH 3/5] feat: add unit test to email verification mutations --- package-lock.json | 4 +- src/resolvers/userResolver.test.ts | 225 ++++++++++++++++++++++++++++- src/resolvers/userResolver.ts | 11 +- test/graphqlQueries.ts | 12 ++ 4 files changed, 239 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index f73dc5295..54152c452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "giveth-graphql-api", - "version": "1.24.3", + "version": "1.24.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "giveth-graphql-api", - "version": "1.24.3", + "version": "1.24.4", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index 920a9ea8d..02d9b3c72 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -18,10 +18,15 @@ import { } from '../../test/testUtils'; import { refreshUserScores, + sendCodeToConfirmEmail, updateUser, userByAddress, + verifyUserEmailCode as verifyUserEmailCodeQuery, } from '../../test/graphqlQueries'; -import { errorMessages } from '../utils/errorMessages'; +import { + errorMessages, + translationErrorMessagesKeys, +} from '../utils/errorMessages'; import { insertSinglePowerBoosting } from '../repositories/powerBoostingRepository'; import { DONATION_STATUS } from '../entities/donation'; import { getGitcoinAdapter } from '../adapters/adaptersFactory'; @@ -33,7 +38,12 @@ import { RECURRING_DONATION_STATUS } from '../entities/recurringDonation'; describe('updateUser() test cases', updateUserTestCases); describe('userByAddress() test cases', userByAddressTestCases); describe('refreshUserScores() test cases', refreshUserScoresTestCases); -describe('confirmUserEmail() test cases', confirmUserEmailTestCases); +describe( + 'sendUserEmailConfirmationCodeFlow() test cases', + sendUserEmailConfirmationCodeFlow, +); +describe('verifyUserEmailCode() test cases', verifyUserEmailCode); + // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); function refreshUserScoresTestCases() { @@ -926,8 +936,215 @@ function updateUserTestCases() { assert.equal(updatedUser?.url, updateUserData.url); }); } -function confirmUserEmailTestCases(): void { - it('should confirm user email', async () => { +function sendUserEmailConfirmationCodeFlow(): void { + it('should send the email', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + assert.isNotNull(updatedUser?.verificationCode); + }); + it('should fail send the email not logged in', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + + try { + await axios.post(graphqlUrl, { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.AUTHENTICATION_REQUIRED, + ); + } + }); + it('should fail send the email user already has verified email', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; + await user.save(); + const accessToken = await generateTestAccessToken(user.id); + + try { + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED, + ); + } + }); +} + +function verifyUserEmailCode() { + it('should verify the email code', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + await axios.post( + graphqlUrl, + { + query: verifyUserEmailCodeQuery, + variables: { + code: updatedUser?.verificationCode, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const verifiedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + assert.isTrue(verifiedUser?.isEmailVerified); + }); + it('should fail verify the email code not logged in', async () => { + try { + await axios.post(graphqlUrl, { + query: verifyUserEmailCodeQuery, + variables: { + code: 1234, + }, + }); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.AUTHENTICATION_REQUIRED, + ); + } + }); + it('should fail verify the email code when code is wrong', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: user.email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + + assert.isNotNull(updatedUser?.verificationCode); + + if (!updatedUser?.verificationCode) return; + + try { + await axios.post( + graphqlUrl, + { + query: verifyUserEmailCodeQuery, + variables: { + code: updatedUser?.verificationCode + 1, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.INVALID_EMAIL_CODE, + ); + } + }); + it('should fail verify the email user already verified', async () => { const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; + await user.save(); + const accessToken = await generateTestAccessToken(user.id); + + try { + await axios.post( + graphqlUrl, + { + query: verifyUserEmailCodeQuery, + variables: { + code: 1234, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch (e) { + assert.equal( + e.response.data.errors[0].message, + translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED, + ); + } }); } diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index a7d5e3151..950f0375c 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -234,15 +234,12 @@ export class UserResolver { } @Mutation(_returns => Boolean) - async confirmUserEmail( + async sendUserEmailConfirmationCodeFlow( @Arg('email') email: string, @Ctx() ctx: ApolloContext, ): Promise { const user = await getLoggedInUser(ctx); - if (!user) - throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); - if (user.isEmailVerified) throw new Error( i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED), @@ -250,20 +247,20 @@ export class UserResolver { const code = generateEmailVerificationCode(); - user.verificationCode = code; - await getNotificationAdapter().sendEmailConfirmationCodeFlow({ email: email, user: user, }); + user.verificationCode = code; + await user.save(); return true; } @Mutation(_returns => Boolean) - async verifyUserEmail( + async verifyUserEmailCode( @Arg('code') code: number, @Ctx() ctx: ApolloContext, ): Promise { diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index c05b84047..2e3d5ac9b 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2584,3 +2584,15 @@ export const fetchRecurringDonationsByDateQuery = ` } } `; + +export const sendCodeToConfirmEmail = ` + mutation sendCodeToConfirmEmail($email: String!) { + sendUserEmailConfirmationCodeFlow(email: $email) + } +`; + +export const verifyUserEmailCode = ` + mutation verifyUserEmailCode($code: Float!) { + verifyUserEmailCode(code: $code) + } +`; From a947c1b51ec5131a70c04943953128c2f3e32aa8 Mon Sep 17 00:00:00 2001 From: Reshzera Date: Wed, 14 Aug 2024 15:58:18 -0300 Subject: [PATCH 4/5] fix: user entity type --- src/entities/user.ts | 4 ++-- src/resolvers/userResolver.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/entities/user.ts b/src/entities/user.ts index e1eff4c5f..658a75325 100644 --- a/src/entities/user.ts +++ b/src/entities/user.ts @@ -160,8 +160,8 @@ export class User extends BaseEntity { @Column('bool', { default: false }) isEmailVerified: boolean; - @Column({ nullable: true, default: null }) - verificationCode?: number; + @Column('numeric', { nullable: true, default: null }) + verificationCode?: number | null; // Admin Reviewing Forms @Field(_type => [ProjectVerificationForm], { nullable: true }) diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 950f0375c..411dc6231 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -278,7 +278,7 @@ export class UserResolver { throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL_CODE)); user.isEmailVerified = true; - user.verificationCode = undefined; + user.verificationCode = null; await user.save(); From fe94dc4e93a0d84f0dc62653e2ca5dd3460e7910 Mon Sep 17 00:00:00 2001 From: Reshzera Date: Wed, 21 Aug 2024 11:13:08 -0300 Subject: [PATCH 5/5] feat: add email sent flag --- .../1723123773224-add_is_email_verified.ts | 18 ----- ...723581552603-add_verification_code_user.ts | 20 ----- ...64281125-add_email_verification_columns.ts | 21 +++++ src/entities/user.ts | 5 +- src/resolvers/types/userResolver.ts | 4 + src/resolvers/userResolver.test.ts | 76 +++++++++++++++++-- src/resolvers/userResolver.ts | 47 +++++++++--- src/utils/errorMessages.ts | 6 +- src/utils/locales/en.json | 5 +- src/utils/locales/es.json | 5 +- src/utils/validators/commonValidators.ts | 6 +- test/graphqlQueries.ts | 8 +- 12 files changed, 152 insertions(+), 69 deletions(-) delete mode 100644 migration/1723123773224-add_is_email_verified.ts delete mode 100644 migration/1723581552603-add_verification_code_user.ts create mode 100644 migration/1723764281125-add_email_verification_columns.ts diff --git a/migration/1723123773224-add_is_email_verified.ts b/migration/1723123773224-add_is_email_verified.ts deleted file mode 100644 index e169a003b..000000000 --- a/migration/1723123773224-add_is_email_verified.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; - -export class AddIsEmailVerified1723123773224 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.addColumn( - 'user', - new TableColumn({ - name: 'isEmailVerified', - type: 'boolean', - default: false, - }), - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropColumn('user', 'isEmailVerified'); - } -} diff --git a/migration/1723581552603-add_verification_code_user.ts b/migration/1723581552603-add_verification_code_user.ts deleted file mode 100644 index 626db44e0..000000000 --- a/migration/1723581552603-add_verification_code_user.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; - -export class AddVerificationCodeUser1723581552603 - implements MigrationInterface -{ - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.addColumn( - 'user', - new TableColumn({ - name: 'verificationCode', - type: 'int', - isNullable: true, - }), - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropColumn('user', 'verificationCode'); - } -} diff --git a/migration/1723764281125-add_email_verification_columns.ts b/migration/1723764281125-add_email_verification_columns.ts new file mode 100644 index 000000000..013b318e5 --- /dev/null +++ b/migration/1723764281125-add_email_verification_columns.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEmailVerificationColumns1723764281125 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(` + ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS "verificationCode" 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 "verificationCode", + DROP COLUMN IF EXISTS "isEmailVerified"; + `); + } +} diff --git a/src/entities/user.ts b/src/entities/user.ts index 658a75325..f40e058ac 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 { @@ -160,8 +161,8 @@ export class User extends BaseEntity { @Column('bool', { default: false }) isEmailVerified: boolean; - @Column('numeric', { nullable: true, default: null }) - verificationCode?: number | null; + @Column('varchar', { nullable: true, default: null }) + verificationCode?: string | null; // Admin Reviewing Forms @Field(_type => [ProjectVerificationForm], { nullable: true }) diff --git a/src/resolvers/types/userResolver.ts b/src/resolvers/types/userResolver.ts index 94a9c5761..f4f1a1407 100644 --- a/src/resolvers/types/userResolver.ts +++ b/src/resolvers/types/userResolver.ts @@ -5,4 +5,8 @@ import { User } from '../../entities/user'; export class UserByAddressResponse extends User { @Field(_type => Boolean, { nullable: true }) isSignedIn?: boolean; + @Field(_type => Boolean, { nullable: true }) + isEmailSent?: boolean; + @Field(_type => Boolean, { nullable: true }) + useHasProject?: boolean; } diff --git a/src/resolvers/userResolver.test.ts b/src/resolvers/userResolver.test.ts index 02d9b3c72..df37d072a 100644 --- a/src/resolvers/userResolver.test.ts +++ b/src/resolvers/userResolver.test.ts @@ -22,6 +22,7 @@ import { updateUser, userByAddress, verifyUserEmailCode as verifyUserEmailCodeQuery, + checkEmailAvailability as checkEmailAvailabilityQuery, } from '../../test/graphqlQueries'; import { errorMessages, @@ -43,6 +44,7 @@ describe( sendUserEmailConfirmationCodeFlow, ); describe('verifyUserEmailCode() test cases', verifyUserEmailCode); +describe('checkEmailAvailability()', checkEmailAvailability); // TODO I think we can delete addUserVerification query // describe('addUserVerification() test cases', addUserVerificationTestCases); @@ -979,7 +981,38 @@ function sendUserEmailConfirmationCodeFlow(): void { ); } }); - it('should fail send the email user already has verified email', async () => { + it('should set the user verification false, user already has been verified', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + user.isEmailVerified = true; + await user.save(); + const accessToken = await generateTestAccessToken(user.id); + + await axios.post( + graphqlUrl, + { + query: sendCodeToConfirmEmail, + variables: { + email: 'newemail@test.com', + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + const updatedUser = await User.findOne({ + where: { + id: user.id, + }, + }); + + assert.isNotNull(updatedUser?.verificationCode); + assert.isFalse(updatedUser?.isEmailVerified); + assert.equal(updatedUser?.email, 'newemail@test.com'); + }); + it('should fail send the email when email is already verified', async () => { const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); user.isEmailVerified = true; await user.save(); @@ -1003,7 +1036,7 @@ function sendUserEmailConfirmationCodeFlow(): void { } catch (e) { assert.equal( e.response.data.errors[0].message, - translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED, + translationErrorMessagesKeys.EMAIL_ALREADY_USED, ); } }); @@ -1046,11 +1079,13 @@ function verifyUserEmailCode() { }, }, ); + const verifiedUser = await User.findOne({ where: { id: user.id, }, }); + assert.isTrue(verifiedUser?.isEmailVerified); }); it('should fail verify the email code not logged in', async () => { @@ -1119,19 +1154,44 @@ function verifyUserEmailCode() { ); } }); - it('should fail verify the email user already verified', async () => { +} + +function checkEmailAvailability() { + it('should return true when email is available', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + const email = 'test@gmail.com'; + + const result = await axios.post( + graphqlUrl, + { + query: checkEmailAvailabilityQuery, + variables: { + email, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + assert.isTrue(result.data.data.checkEmailAvailability); + }); + + it('should return false when email is not available', async () => { const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); - user.isEmailVerified = true; - await user.save(); const accessToken = await generateTestAccessToken(user.id); + const email = user.email; try { await axios.post( graphqlUrl, { - query: verifyUserEmailCodeQuery, + query: checkEmailAvailabilityQuery, variables: { - code: 1234, + email, }, }, { @@ -1143,7 +1203,7 @@ function verifyUserEmailCode() { } catch (e) { assert.equal( e.response.data.errors[0].message, - translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED, + translationErrorMessagesKeys.EMAIL_ALREADY_USED, ); } }); diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 411dc6231..3c8f11e85 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -71,11 +71,13 @@ export class UserResolver { address, includeSensitiveFields, ); + if (!foundUser) { throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); } return { isSignedIn: Boolean(user), + isEmailSent: !!foundUser.verificationCode && !foundUser.isEmailVerified, ...foundUser, }; } @@ -239,20 +241,27 @@ export class UserResolver { @Ctx() ctx: ApolloContext, ): Promise { const user = await getLoggedInUser(ctx); + const isEmailAlreadyUsed = await User.findOne({ + where: { email: email }, + }); - if (user.isEmailVerified) - throw new Error( - i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED), - ); + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (!!isEmailAlreadyUsed && isEmailAlreadyUsed.isEmailVerified) + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_USED)); - const code = generateEmailVerificationCode(); + const code = generateEmailVerificationCode().toString(); + + user.verificationCode = code; await getNotificationAdapter().sendEmailConfirmationCodeFlow({ email: email, user: user, }); - user.verificationCode = code; + user.email = email; + user.isEmailVerified = false; await user.save(); @@ -261,7 +270,7 @@ export class UserResolver { @Mutation(_returns => Boolean) async verifyUserEmailCode( - @Arg('code') code: number, + @Arg('code') code: string, @Ctx() ctx: ApolloContext, ): Promise { const user = await getLoggedInUser(ctx); @@ -269,11 +278,6 @@ export class UserResolver { if (!user) throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); - if (user.isEmailVerified) - throw new Error( - i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_VERIFIED), - ); - if (user.verificationCode !== code) throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_EMAIL_CODE)); @@ -284,4 +288,23 @@ export class UserResolver { return true; } + + @Mutation(_returns => Boolean) + async checkEmailAvailability( + @Arg('email') email: string, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + const isEmailAlreadyUsed = await User.findOne({ + where: { email: email }, + }); + + if (!user) + throw new Error(i18n.__(translationErrorMessagesKeys.USER_NOT_FOUND)); + + if (!!isEmailAlreadyUsed && isEmailAlreadyUsed.isEmailVerified) + throw new Error(i18n.__(translationErrorMessagesKeys.EMAIL_ALREADY_USED)); + + return true; + } } diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 996caf13e..155b184b8 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -205,8 +205,9 @@ 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', - EMAIL_ALREADY_VERIFIED: 'Email already verified', + USER_ALREADY_VERIFIED: 'User already verified', INVALID_EMAIL_CODE: 'Invalid email code', + EMAIL_ALREADY_USED: 'Email already used', }; export const translationErrorMessagesKeys = { @@ -381,6 +382,7 @@ 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', - EMAIL_ALREADY_VERIFIED: 'EMAIL_ALREADY_VERIFIED', + USER_ALREADY_VERIFIED: 'USER_ALREADY_VERIFIED', INVALID_EMAIL_CODE: 'INVALID_EMAIL_CODE', + EMAIL_ALREADY_USED: 'EMAIL_ALREADY_USED', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 079e1bbf1..f9e8675d4 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -118,6 +118,7 @@ "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", - "EMAIL_ALREADY_VERIFIED": "Email already verified", - "INVALID_EMAIL_CODE": "Invalid email code" + "USER_ALREADY_VERIFIED": "User already verified", + "INVALID_EMAIL_CODE": "Invalid email code", + "EMAIL_ALREADY_USED": "Email already used" } \ No newline at end of file diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index e844b6954..2778020c1 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -107,6 +107,7 @@ "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", - "EMAIL_ALREADY_VERIFIED": "El correo electrónico ya ha sido verificado", - "INVALID_EMAIL_CODE": "Código de correo electrónico no válido" + "USER_ALREADY_VERIFIED": "El usuario ya está verificado", + "INVALID_EMAIL_CODE": "Código de correo electrónico no válido", + "EMAIL_ALREADY_USED": "El correo electrónico ya ha sido utilizado" } diff --git a/src/utils/validators/commonValidators.ts b/src/utils/validators/commonValidators.ts index 59b33dd61..f4bc27edb 100644 --- a/src/utils/validators/commonValidators.ts +++ b/src/utils/validators/commonValidators.ts @@ -1,5 +1,7 @@ export const validateEmail = (email: string): boolean => { - return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - email, + return ( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + email, + ) || email === '' ); }; diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 2e3d5ac9b..fccba158e 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -2592,7 +2592,13 @@ export const sendCodeToConfirmEmail = ` `; export const verifyUserEmailCode = ` - mutation verifyUserEmailCode($code: Float!) { + mutation verifyUserEmailCode($code: String!) { verifyUserEmailCode(code: $code) } `; + +export const checkEmailAvailability = ` + mutation checkEmailAvailability($email: String!) { + checkEmailAvailability(email: $email) + } +`;