mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-24 21:32:28 +00:00
feat: onboarding signup journey
This commit is contained in:
@ -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 {}
|
||||
|
24
src/auth/controllers/auth.v2.controller.ts
Normal file
24
src/auth/controllers/auth.v2.controller.ts
Normal file
@ -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));
|
||||
}
|
||||
}
|
@ -1 +1,2 @@
|
||||
export * from './auth.controller';
|
||||
export * from './auth.v2.controller';
|
||||
|
@ -0,0 +1,4 @@
|
||||
import { OmitType } from '@nestjs/swagger';
|
||||
import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto';
|
||||
|
||||
export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {}
|
@ -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';
|
||||
|
71
src/auth/dtos/request/verify-user.v2.request.dto.ts
Normal file
71
src/auth/dtos/request/verify-user.v2.request.dto.ts
Normal file
@ -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;
|
||||
}
|
10
src/auth/dtos/response/send-register-otp.v2.response.dto.ts
Normal file
10
src/auth/dtos/response/send-register-otp.v2.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SendRegisterOtpV2ResponseDto {
|
||||
@ApiProperty()
|
||||
maskedNumber!: string;
|
||||
|
||||
constructor(maskedNumber: string) {
|
||||
this.maskedNumber = maskedNumber;
|
||||
}
|
||||
}
|
@ -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 });
|
||||
|
@ -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": {
|
||||
|
@ -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.",
|
||||
|
@ -11,8 +11,7 @@ export class UserRepository {
|
||||
createUnverifiedUser(data: Partial<User>) {
|
||||
return this.userRepository.save(
|
||||
this.userRepository.create({
|
||||
email: data.email,
|
||||
roles: data.roles,
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -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 });
|
||||
|
Reference in New Issue
Block a user