mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-25 13:49:40 +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 { JwtModule } from '@nestjs/jwt';
|
||||||
import { JuniorModule } from '~/junior/junior.module';
|
import { JuniorModule } from '~/junior/junior.module';
|
||||||
import { UserModule } from '~/user/user.module';
|
import { UserModule } from '~/user/user.module';
|
||||||
import { AuthController } from './controllers';
|
import { AuthController, AuthV2Controller } from './controllers';
|
||||||
import { AuthService, Oauth2Service } from './services';
|
import { AuthService, Oauth2Service } from './services';
|
||||||
import { AccessTokenStrategy } from './strategies';
|
import { AccessTokenStrategy } from './strategies';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
|
imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
|
||||||
providers: [AuthService, AccessTokenStrategy, Oauth2Service],
|
providers: [AuthService, AccessTokenStrategy, Oauth2Service],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, AuthV2Controller],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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.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 './apple-login.request.dto';
|
||||||
export * from './create-unverified-user.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 './disable-biometric.request.dto';
|
||||||
export * from './enable-biometric.request.dto';
|
export * from './enable-biometric.request.dto';
|
||||||
export * from './forget-password.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-login-otp.request.dto';
|
||||||
export * from './verify-otp.request.dto';
|
export * from './verify-otp.request.dto';
|
||||||
export * from './verify-user.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 {
|
import {
|
||||||
AppleLoginRequestDto,
|
AppleLoginRequestDto,
|
||||||
CreateUnverifiedUserRequestDto,
|
CreateUnverifiedUserRequestDto,
|
||||||
|
CreateUnverifiedUserV2RequestDto,
|
||||||
DisableBiometricRequestDto,
|
DisableBiometricRequestDto,
|
||||||
EnableBiometricRequestDto,
|
EnableBiometricRequestDto,
|
||||||
ForgetPasswordRequestDto,
|
ForgetPasswordRequestDto,
|
||||||
@ -25,6 +26,7 @@ import {
|
|||||||
setJuniorPasswordRequestDto,
|
setJuniorPasswordRequestDto,
|
||||||
VerifyLoginOtpRequestDto,
|
VerifyLoginOtpRequestDto,
|
||||||
VerifyUserRequestDto,
|
VerifyUserRequestDto,
|
||||||
|
VerifyUserV2RequestDto,
|
||||||
} from '../dtos/request';
|
} from '../dtos/request';
|
||||||
import { Roles } from '../enums';
|
import { Roles } from '../enums';
|
||||||
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
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]> {
|
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
|
||||||
this.logger.log(`Verifying user with email ${verifyUserDto.email}`);
|
this.logger.log(`Verifying user with email ${verifyUserDto.email}`);
|
||||||
const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email });
|
const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email });
|
||||||
@ -89,6 +110,39 @@ export class AuthService {
|
|||||||
return [tokens, user];
|
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) {
|
async setEmail(userId: string, { email }: SetEmailRequestDto) {
|
||||||
this.logger.log(`Setting email for user with id ${userId}`);
|
this.logger.log(`Setting email for user with id ${userId}`);
|
||||||
const user = await this.userService.findUserOrThrow({ id: userId });
|
const user = await this.userService.findUserOrThrow({ id: userId });
|
||||||
|
@ -25,7 +25,8 @@
|
|||||||
"ALREADY_EXISTS": "المستخدم موجود بالفعل.",
|
"ALREADY_EXISTS": "المستخدم موجود بالفعل.",
|
||||||
"NOT_FOUND": "لم يتم العثور على المستخدم.",
|
"NOT_FOUND": "لم يتم العثور على المستخدم.",
|
||||||
"PHONE_NUMBER_ALREADY_EXISTS": "رقم الهاتف موجود بالفعل.",
|
"PHONE_NUMBER_ALREADY_EXISTS": "رقم الهاتف موجود بالفعل.",
|
||||||
"JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا."
|
"JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا.",
|
||||||
|
"PHONE_NUMBER_ALREADY_TAKEN": "رقم الهاتف الذي أدخلته مستخدم بالفعل. يرجى استخدام رقم هاتف آخر."
|
||||||
},
|
},
|
||||||
|
|
||||||
"ALLOWANCE": {
|
"ALLOWANCE": {
|
||||||
|
@ -25,7 +25,8 @@
|
|||||||
"ALREADY_EXISTS": "The user already exists.",
|
"ALREADY_EXISTS": "The user already exists.",
|
||||||
"NOT_FOUND": "The user was not found.",
|
"NOT_FOUND": "The user was not found.",
|
||||||
"PHONE_NUMBER_ALREADY_EXISTS": "The phone number already exists.",
|
"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": {
|
"ALLOWANCE": {
|
||||||
"START_DATE_BEFORE_TODAY": "The start date cannot be before today.",
|
"START_DATE_BEFORE_TODAY": "The start date cannot be before today.",
|
||||||
|
@ -11,8 +11,7 @@ export class UserRepository {
|
|||||||
createUnverifiedUser(data: Partial<User>) {
|
createUnverifiedUser(data: Partial<User>) {
|
||||||
return this.userRepository.save(
|
return this.userRepository.save(
|
||||||
this.userRepository.create({
|
this.userRepository.create({
|
||||||
email: data.email,
|
...data,
|
||||||
roles: data.roles,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,12 @@ import { Transactional } from 'typeorm-transactional';
|
|||||||
import { CountryIso } from '~/common/enums';
|
import { CountryIso } from '~/common/enums';
|
||||||
import { NotificationsService } from '~/common/modules/notification/services';
|
import { NotificationsService } from '~/common/modules/notification/services';
|
||||||
import { CustomerService } from '~/customer/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 { Roles } from '../../auth/enums';
|
||||||
import {
|
import {
|
||||||
CreateCheckerRequestDto,
|
CreateCheckerRequestDto,
|
||||||
@ -55,8 +60,8 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async verifyUser(userId: string, body: VerifyUserRequestDto) {
|
async verifyUser(userId: string, body: VerifyUserRequestDto | VerifyUserV2RequestDto) {
|
||||||
this.logger.log(`Verifying user email with id ${userId}`);
|
this.logger.log(`Verifying user with id ${userId}`);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.customerService.createGuardianCustomer(userId, {
|
this.customerService.createGuardianCustomer(userId, {
|
||||||
firstName: body.firstName,
|
firstName: body.firstName,
|
||||||
@ -64,7 +69,27 @@ export class UserService {
|
|||||||
dateOfBirth: body.dateOfBirth,
|
dateOfBirth: body.dateOfBirth,
|
||||||
countryOfResidence: body.countryOfResidence,
|
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;
|
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) {
|
async findOrCreateByEmail(email: string) {
|
||||||
this.logger.log(`Finding or creating user with email ${email} `);
|
this.logger.log(`Finding or creating user with email ${email} `);
|
||||||
const user = await this.userRepository.findOne({ email });
|
const user = await this.userRepository.findOne({ email });
|
||||||
|
Reference in New Issue
Block a user