diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 138e689..60dd928 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,14 +3,14 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; -import { AuthController } from './controllers'; +import { AuthController, AuthV2Controller } from './controllers'; import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], providers: [AuthService, AccessTokenStrategy, Oauth2Service], - controllers: [AuthController], + controllers: [AuthController, AuthV2Controller], exports: [], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts new file mode 100644 index 0000000..af8b147 --- /dev/null +++ b/src/auth/controllers/auth.v2.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ResponseFactory } from '~/core/utils'; +import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; +import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { SendRegisterOtpV2ResponseDto } from '../dtos/response/send-register-otp.v2.response.dto'; +import { AuthService } from '../services'; + +@Controller('auth/v2') +@ApiTags('Auth V2') +export class AuthV2Controller { + constructor(private readonly authService: AuthService) {} + @Post('register/otp') + async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { + const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); + return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); + } + + @Post('register/verify') + async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { + const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); + } +} diff --git a/src/auth/controllers/index.ts b/src/auth/controllers/index.ts index 04d02fa..37a52b8 100644 --- a/src/auth/controllers/index.ts +++ b/src/auth/controllers/index.ts @@ -1 +1,2 @@ export * from './auth.controller'; +export * from './auth.v2.controller'; diff --git a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts new file mode 100644 index 0000000..7df2980 --- /dev/null +++ b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto'; + +export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index d5c81cf..ab51125 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,5 +1,6 @@ export * from './apple-login.request.dto'; export * from './create-unverified-user.request.dto'; +export * from './create-unverified-user.request.v2.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; @@ -14,3 +15,4 @@ 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'; +export * from './verify-user.v2.request.dto'; diff --git a/src/auth/dtos/request/verify-user.v2.request.dto.ts b/src/auth/dtos/request/verify-user.v2.request.dto.ts new file mode 100644 index 0000000..ce0f216 --- /dev/null +++ b/src/auth/dtos/request/verify-user.v2.request.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDateString, + IsEmail, + IsEnum, + IsNotEmpty, + IsNumberString, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; +import { CountryIso } from '~/common/enums'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; + +export class VerifyUserV2RequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + lastName!: string; + + @ApiProperty({ example: '2021-01-01' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) + dateOfBirth!: Date; + + @ApiProperty({ example: 'JO' }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) + @IsOptional() + countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @IsOptional() + email!: string; + + @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/dtos/response/send-register-otp.v2.response.dto.ts b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts new file mode 100644 index 0000000..27c72ec --- /dev/null +++ b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SendRegisterOtpV2ResponseDto { + @ApiProperty() + maskedNumber!: string; + + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 0d192b2..908e6ed 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -14,6 +14,7 @@ import { PASSCODE_REGEX } from '../constants'; import { AppleLoginRequestDto, CreateUnverifiedUserRequestDto, + CreateUnverifiedUserV2RequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, @@ -25,6 +26,7 @@ import { setJuniorPasswordRequestDto, VerifyLoginOtpRequestDto, VerifyUserRequestDto, + VerifyUserV2RequestDto, } from '../dtos/request'; import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; @@ -59,6 +61,25 @@ export class AuthService { }); } + async sendPhoneRegisterOtp(body: CreateUnverifiedUserV2RequestDto) { + if (body.email) { + const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); + if (isEmailUsed) { + this.logger.error(`Email ${body.email} is already used`); + throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); + } + } + + this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); + const user = await this.userService.findOrCreateByPhoneNumber(body); + return this.otpService.generateAndSendOtp({ + userId: user.id, + recipient: user.fullPhoneNumber, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + }); + } + async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { this.logger.log(`Verifying user with email ${verifyUserDto.email}`); const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email }); @@ -89,6 +110,39 @@ export class AuthService { return [tokens, user]; } + async verifyUserV2(verifyUserDto: VerifyUserV2RequestDto): Promise<[ILoginResponse, User]> { + this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); + const user = await this.userService.findUserOrThrow({ + phoneNumber: verifyUserDto.phoneNumber, + countryCode: verifyUserDto.countryCode, + }); + + if (user.isPhoneVerified) { + this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED'); + } + + const isOtpValid = await this.otpService.verifyOtp({ + userId: user.id, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + value: verifyUserDto.otp, + }); + + if (!isOtpValid) { + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); + throw new BadRequestException('OTP.INVALID_OTP'); + } + + await this.userService.verifyUserV2(user.id, verifyUserDto); + + await user.reload(); + + const tokens = await this.generateAuthToken(user); + this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); + return [tokens, user]; + } + async setEmail(userId: string, { email }: SetEmailRequestDto) { this.logger.log(`Setting email for user with id ${userId}`); const user = await this.userService.findUserOrThrow({ id: userId }); diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 754a4b5..455ddaf 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -25,7 +25,8 @@ "ALREADY_EXISTS": "المستخدم موجود بالفعل.", "NOT_FOUND": "لم يتم العثور على المستخدم.", "PHONE_NUMBER_ALREADY_EXISTS": "رقم الهاتف موجود بالفعل.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا.", + "PHONE_NUMBER_ALREADY_TAKEN": "رقم الهاتف الذي أدخلته مستخدم بالفعل. يرجى استخدام رقم هاتف آخر." }, "ALLOWANCE": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 677a7df..105afc9 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -25,7 +25,8 @@ "ALREADY_EXISTS": "The user already exists.", "NOT_FOUND": "The user was not found.", "PHONE_NUMBER_ALREADY_EXISTS": "The phone number already exists.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported.", + "PHONE_NUMBER_ALREADY_TAKEN": "The phone number you entered is already in use. Please use a different phone number." }, "ALLOWANCE": { "START_DATE_BEFORE_TODAY": "The start date cannot be before today.", diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index 79b8489..d740cd8 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -11,8 +11,7 @@ export class UserRepository { createUnverifiedUser(data: Partial) { return this.userRepository.save( this.userRepository.create({ - email: data.email, - roles: data.roles, + ...data, }), ); } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index ec32953..3279780 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -7,7 +7,12 @@ import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; import { CustomerService } from '~/customer/services'; -import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; +import { + CreateUnverifiedUserRequestDto, + CreateUnverifiedUserV2RequestDto, + VerifyUserRequestDto, + VerifyUserV2RequestDto, +} from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; import { CreateCheckerRequestDto, @@ -55,8 +60,8 @@ export class UserService { } @Transactional() - async verifyUser(userId: string, body: VerifyUserRequestDto) { - this.logger.log(`Verifying user email with id ${userId}`); + async verifyUser(userId: string, body: VerifyUserRequestDto | VerifyUserV2RequestDto) { + this.logger.log(`Verifying user with id ${userId}`); await Promise.all([ this.customerService.createGuardianCustomer(userId, { firstName: body.firstName, @@ -64,7 +69,27 @@ export class UserService { dateOfBirth: body.dateOfBirth, countryOfResidence: body.countryOfResidence, }), - this.userRepository.update(userId, { isEmailVerified: true }), + this.userRepository.update(userId, { + isEmailVerified: true, + }), + ]); + } + + @Transactional() + async verifyUserV2(userId: string, body: VerifyUserV2RequestDto) { + this.logger.log(`Verifying user with id ${userId}`); + await Promise.all([ + this.customerService.createGuardianCustomer(userId, { + firstName: body.firstName, + lastName: body.lastName, + dateOfBirth: body.dateOfBirth, + countryOfResidence: body.countryOfResidence, + }), + this.userRepository.update(userId, { + isPhoneVerified: true, + + ...(body.email && { email: body.email }), + }), ]); } @@ -110,6 +135,38 @@ export class UserService { return user; } + async findOrCreateByPhoneNumber(body: CreateUnverifiedUserV2RequestDto) { + this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`); + const user = await this.userRepository.findOne({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + }); + + if (!user) { + this.logger.log(`User with phone number ${body.phoneNumber} not found, creating new user`); + return this.userRepository.createUnverifiedUser({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + email: body.email, + roles: [Roles.GUARDIAN], + }); + } + + if (user && user.roles.includes(Roles.GUARDIAN) && user.isPhoneVerified) { + this.logger.error(`User with phone number ${body.phoneNumber} already exists`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); + } + + if (user && user.roles.includes(Roles.JUNIOR)) { + this.logger.error(`User with phone number ${body.phoneNumber} 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 phone number ${body.phoneNumber} found successfully`); + return user; + } + async findOrCreateByEmail(email: string) { this.logger.log(`Finding or creating user with email ${email} `); const user = await this.userRepository.findOne({ email });