From 9b5f863577a1b393d5298e72b3fbf957c07e324b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 4 Mar 2025 14:42:02 +0300 Subject: [PATCH] add login flow for waiting list demo app --- src/auth/controllers/auth.controller.ts | 18 +++++++++- src/auth/dtos/request/index.ts | 2 ++ .../request/send-login-otp.request.dto.ts | 9 +++++ .../request/verify-login-otp.request.dto.ts | 20 +++++++++++ src/auth/services/auth.service.ts | 35 +++++++++++++++++++ .../services/notifications.service.ts | 4 ++- src/common/modules/otp/entities/otp.entity.ts | 3 ++ .../modules/otp/enums/otp-scope.enum.ts | 1 + .../otp/repositories/otp.repository.ts | 7 +++- .../modules/otp/services/otp.service.ts | 7 +++- .../request/create-customer.request.dto.ts | 29 +++++++++------ src/customer/entities/customer.entity.ts | 8 ++--- src/customer/services/customer.service.ts | 9 +++-- ...p-and-remove-constraints-from-customers.ts | 33 +++++++++++++++++ src/db/migrations/index.ts | 1 + src/user/services/user.service.ts | 19 ++++++++++ 16 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 src/auth/dtos/request/send-login-otp.request.dto.ts create mode 100644 src/auth/dtos/request/verify-login-otp.request.dto.ts create mode 100644 src/db/migrations/1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers.ts diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 441ea09..ab1f8f6 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -4,7 +4,7 @@ import { Request } from 'express'; import { DEVICE_ID_HEADER } from '~/common/constants'; import { AuthenticatedUser, Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiLangRequestHeader } from '~/core/decorators'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { CreateUnverifiedUserRequestDto, @@ -14,9 +14,11 @@ import { LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, + SendLoginOtpRequestDto, SetEmailRequestDto, setJuniorPasswordRequestDto, SetPasscodeRequestDto, + VerifyLoginOtpRequestDto, VerifyOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; @@ -43,6 +45,20 @@ export class AuthController { return ResponseFactory.data(new LoginResponseDto(res, user)); } + @Post('login/otp') + @HttpCode(HttpStatus.NO_CONTENT) + async sendLoginOtp(@Body() data: SendLoginOtpRequestDto) { + return this.authService.sendLoginOtp(data); + } + + @Post('login/verify') + @HttpCode(HttpStatus.OK) + @ApiDataResponse(LoginResponseDto) + async verifyLoginOtp(@Body() data: VerifyLoginOtpRequestDto) { + const [token, user] = await this.authService.verifyLoginOtp(data); + return ResponseFactory.data(new LoginResponseDto(token, user)); + } + @Post('register/set-email') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(AccessTokenGuard) diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index 579a641..bc3643b 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -5,8 +5,10 @@ export * from './forget-password.request.dto'; export * from './login.request.dto'; export * from './refresh-token.request.dto'; export * from './send-forget-password-otp.request.dto'; +export * from './send-login-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; +export * from './verify-login-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/send-login-otp.request.dto.ts b/src/auth/dtos/request/send-login-otp.request.dto.ts new file mode 100644 index 0000000..45388f3 --- /dev/null +++ b/src/auth/dtos/request/send-login-otp.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; + +export class SendLoginOtpRequestDto { + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + email!: string; +} diff --git a/src/auth/dtos/request/verify-login-otp.request.dto.ts b/src/auth/dtos/request/verify-login-otp.request.dto.ts new file mode 100644 index 0000000..819ead5 --- /dev/null +++ b/src/auth/dtos/request/verify-login-otp.request.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumberString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { SendLoginOtpRequestDto } from './send-login-otp.request.dto'; + +export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto { + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 4d35a4e..0279690 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -18,8 +18,10 @@ import { ForgetPasswordRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, + SendLoginOtpRequestDto, SetEmailRequestDto, setJuniorPasswordRequestDto, + VerifyLoginOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { GrantType, Roles } from '../enums'; @@ -325,6 +327,39 @@ export class AuthService { } } + async sendLoginOtp({ email }: SendLoginOtpRequestDto) { + const user = await this.userService.findOrCreateByEmail(email); + this.logger.log(`Sending login OTP to ${email}`); + return this.otpService.generateAndSendOtp({ + recipient: email, + scope: OtpScope.LOGIN, + otpType: OtpType.EMAIL, + userId: user.id, + }); + } + + async verifyLoginOtp({ email, otp }: VerifyLoginOtpRequestDto): Promise<[ILoginResponse, User]> { + const user = await this.userService.findUserOrThrow({ email }); + + this.logger.log(`Verifying login OTP for ${email}`); + const isOtpValid = await this.otpService.verifyOtp({ + otpType: OtpType.EMAIL, + scope: OtpScope.LOGIN, + userId: user.id, + value: otp, + }); + + if (!isOtpValid) { + this.logger.error(`Invalid OTP for user with email ${email}`); + throw new BadRequestException('OTP.INVALID_OTP'); + } + + this.logger.log(`Login OTP verified successfully for ${email}`); + + const token = await this.generateAuthToken(user); + return [token, user]; + } + logout(req: Request) { this.logger.log('Logging out'); const accessToken = req.headers.authorization?.split(' ')[1] as string; diff --git a/src/common/modules/notification/services/notifications.service.ts b/src/common/modules/notification/services/notifications.service.ts index 9e7c5c3..9e2b231 100644 --- a/src/common/modules/notification/services/notifications.service.ts +++ b/src/common/modules/notification/services/notifications.service.ts @@ -56,6 +56,8 @@ export class NotificationsService { scope: NotificationScope.USER_INVITED, channel: NotificationChannel.EMAIL, }); + console.log('++++++++++++++++++++++++='); + console.log(data); return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification, data.data); } @@ -71,7 +73,7 @@ export class NotificationsService { this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`); - return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification); + return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification, { otp }); } private async sendPushNotification(userId: string, title: string, body: string) { diff --git a/src/common/modules/otp/entities/otp.entity.ts b/src/common/modules/otp/entities/otp.entity.ts index ec3b57c..e5eba4a 100644 --- a/src/common/modules/otp/entities/otp.entity.ts +++ b/src/common/modules/otp/entities/otp.entity.ts @@ -24,6 +24,9 @@ export class Otp { @Column('varchar', { name: 'user_id' }) userId!: string; + @Column('boolean', { default: false, name: 'is_used' }) + isUsed!: boolean; + @ManyToOne(() => User, (user) => user.otp, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User; diff --git a/src/common/modules/otp/enums/otp-scope.enum.ts b/src/common/modules/otp/enums/otp-scope.enum.ts index 01d5468..7ecc49a 100644 --- a/src/common/modules/otp/enums/otp-scope.enum.ts +++ b/src/common/modules/otp/enums/otp-scope.enum.ts @@ -1,4 +1,5 @@ export enum OtpScope { VERIFY_PHONE = 'VERIFY_PHONE', FORGET_PASSWORD = 'FORGET_PASSWORD', + LOGIN = 'LOGIN', } diff --git a/src/common/modules/otp/repositories/otp.repository.ts b/src/common/modules/otp/repositories/otp.repository.ts index be1f4ce..879a6f8 100644 --- a/src/common/modules/otp/repositories/otp.repository.ts +++ b/src/common/modules/otp/repositories/otp.repository.ts @@ -14,7 +14,7 @@ export class OtpRepository { createOtp(otp: Partial) { return this.otpRepository.save( this.otpRepository.create({ - userId: otp.userId, + userId: otp.userId ?? undefined, value: otp.value, scope: otp.scope, otpType: otp.otpType, @@ -31,10 +31,15 @@ export class OtpRepository { value: otp.value, otpType: otp.otpType, expiresAt: MoreThan(new Date()), + isUsed: false, }, order: { createdAt: 'DESC', }, }); } + + updateOtp(id: string, data: Partial) { + return this.otpRepository.update(id, data); + } } diff --git a/src/common/modules/otp/services/otp.service.ts b/src/common/modules/otp/services/otp.service.ts index e7431ee..51f6dad 100644 --- a/src/common/modules/otp/services/otp.service.ts +++ b/src/common/modules/otp/services/otp.service.ts @@ -35,11 +35,16 @@ export class OtpService { if (!otp) { this.logger.error( - `OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType}`, + `OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType} or used`, ); return false; } + const { affected } = await this.otpRepository.updateOtp(otp.id, { isUsed: true }); + console.log('+++++++++++++++++++++++++++'); + console.log(affected); + this.logger.log(`OTP verified successfully for ${verifyOtpRequest.userId}`); + return !!otp; } diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts index 4cec8e4..55e5023 100644 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { IsAbove18 } from '~/core/decorators/validations'; import { Gender } from '~/customer/enums'; @@ -16,7 +16,8 @@ export class CreateCustomerRequestDto { @ApiProperty({ example: 'MALE' }) @IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) }) - gender!: Gender; + @IsOptional() + gender?: Gender; @ApiProperty({ example: 'JO' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) @@ -31,39 +32,47 @@ export class CreateCustomerRequestDto { @ApiProperty({ example: '999300024' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.nationalId' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.nationalId' }) }) - nationalId!: string; + @IsOptional() + nationalId?: string; @ApiProperty({ example: '2021-01-01' }) @IsDateString( {}, { message: i18n('validation.IsDateString', { path: 'general', property: 'junior.nationalIdExpiry' }) }, ) - nationalIdExpiry!: Date; + @IsOptional() + nationalIdExpiry?: Date; @ApiProperty({ example: 'Employee' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.sourceOfIncome' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.sourceOfIncome' }) }) - sourceOfIncome!: string; + @IsOptional() + sourceOfIncome?: string; @ApiProperty({ example: 'Accountant' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.profession' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.profession' }) }) - profession!: string; + @IsOptional() + profession?: string; @ApiProperty({ example: 'Finance' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.professionType' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.professionType' }) }) - professionType!: string; + @IsOptional() + professionType?: string; @ApiProperty({ example: false }) @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'junior.isPep' }) }) - isPep!: boolean; + @IsOptional() + isPep?: boolean; @ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' }) @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdFrontId' }) }) - civilIdFrontId!: string; + @IsOptional() + civilIdFrontId?: string; @ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' }) @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdBackId' }) }) - civilIdBackId!: string; + @IsOptional() + civilIdBackId?: string; } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index f844962..4c7b6ea 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -87,17 +87,17 @@ export class Customer extends BaseEntity { @OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true }) guardian!: Guardian; - @Column('uuid', { name: 'civil_id_front_id' }) + @Column('uuid', { name: 'civil_id_front_id', nullable: true }) civilIdFrontId!: string; - @Column('uuid', { name: 'civil_id_back_id' }) + @Column('uuid', { name: 'civil_id_back_id', nullable: true }) civilIdBackId!: string; - @OneToOne(() => Document, (document) => document.customerCivilIdFront) + @OneToOne(() => Document, (document) => document.customerCivilIdFront, { nullable: true }) @JoinColumn({ name: 'civil_id_front_id' }) civilIdFront!: Document; - @OneToOne(() => Document, (document) => document.customerCivilIdBack) + @OneToOne(() => Document, (document) => document.customerCivilIdBack, { nullable: true }) @JoinColumn({ name: 'civil_id_back_id' }) civilIdBack!: Document; diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 76b8905..9822f4e 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -98,7 +98,7 @@ export class CustomerService { throw new BadRequestException('CUSTOMER.ALRADY_EXISTS'); } - await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId); + // await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId); const customer = await this.customerRepository.createCustomer(userId, body, true); this.logger.log(`customer created for user ${userId}`); @@ -166,9 +166,12 @@ export class CustomerService { throw new BadRequestException('CUSTOMER.CIVIL_ID_NOT_CREATED_BY_USER'); } - const customerWithTheSameId = await this.customerRepository.findCustomerByCivilId(civilIdFrontId, civilIdBackId); + const customerWithTheSameCivilId = await this.customerRepository.findCustomerByCivilId( + civilIdFrontId, + civilIdBackId, + ); - if (customerWithTheSameId) { + if (customerWithTheSameCivilId) { this.logger.error( `Customer with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`, ); diff --git a/src/db/migrations/1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers.ts b/src/db/migrations/1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers.ts new file mode 100644 index 0000000..e65a052 --- /dev/null +++ b/src/db/migrations/1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUsedFlagToOtpAndRemoveConstraintsFromCustomers1741087742821 implements MigrationInterface { + name = 'AddUsedFlagToOtpAndRemoveConstraintsFromCustomers1741087742821'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "otp" ADD "is_used" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`); + await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_front_id" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_back_id" DROP NOT NULL`); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`); + await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_back_id" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_front_id" SET NOT NULL`); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query(`ALTER TABLE "otp" DROP COLUMN "is_used"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index d34ea8c..50a4852 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -22,3 +22,4 @@ export * from './1736753223884-add_created_by_to_document_table'; export * from './1739868002943-add-kyc-status-to-customer'; export * from './1739954239949-add-civilid-to-customers-and-update-notifications-settings'; export * from './1740045960580-create-user-registration-table'; +export * from './1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers'; diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 962dad2..46bab16 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -97,6 +97,25 @@ export class UserService { return user; } + async findOrCreateByEmail(email: string) { + this.logger.log(`Finding or creating user with email ${email} `); + const user = await this.userRepository.findOne({ email }); + + if (!user) { + this.logger.log(`User with email ${email} not found, creating new user`); + return this.userRepository.createUser({ email, roles: [Roles.GUARDIAN] }); + } + + if (user && user.roles.includes(Roles.JUNIOR)) { + this.logger.error(`User with email ${email} is an already registered junior`); + throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); + //TODO add role Guardian to the existing user and send OTP + } + + this.logger.log(`User with email ${email} found successfully`); + return user; + } + async createUser(data: Partial) { this.logger.log(`Creating user with data ${JSON.stringify(data)}`); const user = await this.userRepository.createUser(data);