feat: onboarding signup journey

This commit is contained in:
Abdalhamid Alhamad
2025-07-27 13:15:54 +03:00
parent bf43e62b17
commit c493bd57e1
12 changed files with 234 additions and 10 deletions

View File

@ -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 {}

View 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));
}
}

View File

@ -1 +1,2 @@
export * from './auth.controller'; export * from './auth.controller';
export * from './auth.v2.controller';

View File

@ -0,0 +1,4 @@
import { OmitType } from '@nestjs/swagger';
import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto';
export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {}

View File

@ -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';

View 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;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpV2ResponseDto {
@ApiProperty()
maskedNumber!: string;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}

View File

@ -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 });

View File

@ -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": {

View File

@ -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.",

View File

@ -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,
}), }),
); );
} }

View File

@ -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 });