Merge branch 'waiting-list' into feat/neoleap-integration

This commit is contained in:
Abdalhamid Alhamad
2025-06-11 11:15:10 +03:00
10 changed files with 150 additions and 54 deletions

View File

@ -6,11 +6,12 @@ import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
LoginRequestDto,
GoogleLoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
@ -57,6 +58,22 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/google')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithGoogle(@Body() data: GoogleLoginRequestDto) {
const [token, user] = await this.authService.loginWithGoogle(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/apple')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithApple(@Body() data: AppleLoginRequestDto) {
const [token, user] = await this.authService.loginWithApple(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('register/set-email')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
@ -128,12 +145,6 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login')
async login(@Body() loginDto: LoginRequestDto) {
const [res, user] = await this.authService.login(loginDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class AppleAdditionalData {
@ApiProperty({ example: 'Ahmad' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
firstName!: string;
@ApiProperty({ example: 'Khan' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AppleAdditionalData } from './apple-additional-data.request.dto';
export class AppleLoginRequestDto {
@ApiProperty({ example: 'apple_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) })
appleToken!: string;
@ApiProperty({ type: AppleAdditionalData })
@ValidateNested({
each: true,
message: i18n('validation.ValidateNested', { path: 'general', property: 'auth.apple.additionalData' }),
})
@IsOptional()
@Type(() => AppleAdditionalData)
additionalData?: AppleAdditionalData;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class GoogleLoginRequestDto {
@ApiProperty({ example: 'google_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) })
googleToken!: string;
}

View File

@ -1,7 +1,9 @@
export * from './apple-login.request.dto';
export * from './create-unverified-user.request.dto';
export * from './disable-biometric.request.dto';
export * from './enable-biometric.request.dto';
export * from './forget-password.request.dto';
export * from './google-login.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';

View File

@ -12,10 +12,12 @@ import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
GoogleLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
@ -24,7 +26,7 @@ import {
VerifyLoginOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { GrantType, Roles } from '../enums';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { Oauth2Service } from './oauth2.service';
@ -243,29 +245,6 @@ export class AuthService {
this.logger.log(`Passcode updated successfully for user with email ${email}`);
}
async login(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
let user: User;
let tokens: ILoginResponse;
if (loginDto.grantType === GrantType.PASSWORD || loginDto.grantType === GrantType.BIOMETRIC) {
throw new BadRequestException('AUTH.GRANT_TYPE_NOT_SUPPORTED_YET');
}
if (loginDto.grantType === GrantType.GOOGLE) {
this.logger.log(`Logging in user with email ${loginDto.email} using google`);
[tokens, user] = await this.loginWithGoogle(loginDto);
}
if (loginDto.grantType === GrantType.APPLE) {
this.logger.log(`Logging in user with email ${loginDto.email} using apple`);
[tokens, user] = await this.loginWithApple(loginDto);
}
this.logger.log(`User with email ${loginDto.email} logged in successfully`);
return [tokens!, user!];
}
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
@ -403,65 +382,96 @@ export class AuthService {
return [tokens, user];
}
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior] = await Promise.all([
async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> {
const {
email,
sub,
given_name: firstName,
family_name: lastName,
} = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([
this.userService.findUser({ googleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
this.userService.findUser({ email }),
]);
if (isJunior && email) {
if (isJunior) {
this.logger.error(`User with email ${email} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (!existingUser) {
this.logger.debug(`User with google id ${sub} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email);
if (!existingUser && existingUserWithEmail) {
this.logger.error(`User with email ${email} already exists adding google id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
if (!existingUser && !existingUserWithEmail) {
this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email, firstName, lastName);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser);
const tokens = await this.generateAuthToken(existingUser!);
return [tokens, existingUser];
return [tokens, existingUser!];
}
private async loginWithApple(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> {
const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken);
const [existingUser, isJunior] = await Promise.all([
const [existingUserWithSub, isJunior] = await Promise.all([
this.userService.findUser({ appleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
]);
if (isJunior && email) {
this.logger.error(`User with email ${email} is an already registered junior`);
if (isJunior) {
this.logger.error(`User with apple id ${sub} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (!existingUser) {
if (email) {
const existingUserWithEmail = await this.userService.findUser({ email });
if (existingUserWithEmail && !existingUserWithSub) {
{
this.logger.error(`User with email ${email} already exists adding apple id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
}
}
if (!existingUserWithSub) {
// Apple only provides email if user authorized zod for the first time
if (!email) {
if (!email || !loginDto.additionalData) {
this.logger.error(`User authorized zod before but his email is not stored in the database`);
throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED');
}
this.logger.debug(`User with apple id ${sub} not found, creating new user`);
const user = await this.userService.createAppleUser(sub, email);
const user = await this.userService.createAppleUser(
sub,
email,
loginDto.additionalData.firstName,
loginDto.additionalData.lastName,
);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser);
const tokens = await this.generateAuthToken(existingUserWithSub);
this.logger.log(`User with apple id ${sub} logged in successfully`);
return [tokens, existingUser];
return [tokens, existingUserWithSub];
}
private async generateAuthToken(user: User) {
@ -499,4 +509,6 @@ export class AuthService {
throw new BadRequestException('AUTH.INVALID_PASSCODE');
}
}
private validateGoogleToken(googleToken: string) {}
}

View File

@ -41,7 +41,8 @@
"ALREADY_REJECTED": "تم رفض طلب تغيير المصروف بالفعل."
},
"CUSTOMER": {
"NOT_FOUND": "لم يتم العثور على العميل."
"NOT_FOUND": "لم يتم العثور على العميل.",
"ALREADY_EXISTS": "العميل موجود بالفعل."
},
"GIFT": {

View File

@ -40,7 +40,8 @@
"ALREADY_REJECTED": "The allowance change request has already been rejected."
},
"CUSTOMER": {
"NOT_FOUND": "The customer was not found."
"NOT_FOUND": "The customer was not found.",
"ALREADY_EXISTS": "The customer already exists."
},
"GIFT": {

View File

@ -22,7 +22,10 @@
"fcmToken": "FCM token",
"refreshToken": "Refresh token",
"qrToken": "QR token",
"passcode": "Passcode"
"passcode": "Passcode",
"apple": {
"additionalData": "Additional data"
}
},
"customer": {

View File

@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
import moment from 'moment';
import { FindOptionsWhere } from 'typeorm';
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';
@ -180,7 +181,7 @@ export class UserService {
});
}
async createGoogleUser(googleId: string, email: string) {
async createGoogleUser(googleId: string, email: string, firstName: string, lastName: string) {
this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`);
const user = await this.userRepository.createUser({
googleId,
@ -189,10 +190,16 @@ export class UserService {
isEmailVerified: true,
});
await this.customerService.createGuardianCustomer(user.id, {
firstName,
lastName,
countryOfResidence: CountryIso.SAUDI_ARABIA,
});
return this.findUserOrThrow({ id: user.id });
}
async createAppleUser(appleId: string, email: string) {
async createAppleUser(appleId: string, email: string, firstName: string, lastName: string) {
this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`);
const user = await this.userRepository.createUser({
appleId,
@ -201,6 +208,11 @@ export class UserService {
isEmailVerified: true,
});
await this.customerService.createGuardianCustomer(user.id, {
firstName,
lastName,
countryOfResidence: CountryIso.SAUDI_ARABIA,
});
return this.findUserOrThrow({ id: user.id });
}
@ -213,6 +225,15 @@ export class UserService {
return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true });
}
async updateUser(userId: string, data: Partial<User>) {
this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`);
const { affected } = await this.userRepository.update(userId, data);
if (affected === 0) {
this.logger.error(`User with id ${userId} not found`);
throw new BadRequestException('USER.NOT_FOUND');
}
}
private sendCheckerAccountCreatedEmail(email: string, token: string) {
return this.notificationsService.sendEmailAsync({
to: email,