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 { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto, DisableBiometricRequestDto,
EnableBiometricRequestDto, EnableBiometricRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
LoginRequestDto, GoogleLoginRequestDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto, SendLoginOtpRequestDto,
@ -57,6 +58,22 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(token, user)); 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') @Post('register/set-email')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
@ -128,12 +145,6 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user)); 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') @Post('logout')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard) @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 './create-unverified-user.request.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';
export * from './google-login.request.dto';
export * from './login.request.dto'; export * from './login.request.dto';
export * from './refresh-token.request.dto'; export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.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 { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants'; import { PASSCODE_REGEX } from '../constants';
import { import {
AppleLoginRequestDto,
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto, DisableBiometricRequestDto,
EnableBiometricRequestDto, EnableBiometricRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
GoogleLoginRequestDto,
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto, SendLoginOtpRequestDto,
@ -24,7 +26,7 @@ import {
VerifyLoginOtpRequestDto, VerifyLoginOtpRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { GrantType, Roles } from '../enums'; import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces'; import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils'; import { removePadding, verifySignature } from '../utils';
import { Oauth2Service } from './oauth2.service'; import { Oauth2Service } from './oauth2.service';
@ -243,29 +245,6 @@ export class AuthService {
this.logger.log(`Passcode updated successfully for user with email ${email}`); 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) { async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
@ -403,65 +382,96 @@ export class AuthService {
return [tokens, user]; return [tokens, user];
} }
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> {
const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken); const {
const [existingUser, isJunior] = await Promise.all([ 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({ googleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), 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`); this.logger.error(`User with email ${email} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
} }
if (!existingUser) { if (!existingUser && existingUserWithEmail) {
this.logger.debug(`User with google id ${sub} not found, creating new user`); this.logger.error(`User with email ${email} already exists adding google id to existing user`);
const user = await this.userService.createGoogleUser(sub, email); 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); const tokens = await this.generateAuthToken(user);
return [tokens, 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 { 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({ appleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
]); ]);
if (isJunior && email) { if (isJunior) {
this.logger.error(`User with email ${email} is an already registered junior`); this.logger.error(`User with apple id ${sub} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); 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 // 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`); this.logger.error(`User authorized zod before but his email is not stored in the database`);
throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED'); throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED');
} }
this.logger.debug(`User with apple id ${sub} not found, creating new user`); 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); const tokens = await this.generateAuthToken(user);
return [tokens, 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`); this.logger.log(`User with apple id ${sub} logged in successfully`);
return [tokens, existingUser]; return [tokens, existingUserWithSub];
} }
private async generateAuthToken(user: User) { private async generateAuthToken(user: User) {
@ -499,4 +509,6 @@ export class AuthService {
throw new BadRequestException('AUTH.INVALID_PASSCODE'); throw new BadRequestException('AUTH.INVALID_PASSCODE');
} }
} }
private validateGoogleToken(googleToken: string) {}
} }

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
import moment from 'moment'; import moment from 'moment';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { Transactional } from 'typeorm-transactional'; import { Transactional } from 'typeorm-transactional';
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, 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}`); this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`);
const user = await this.userRepository.createUser({ const user = await this.userRepository.createUser({
googleId, googleId,
@ -189,10 +190,16 @@ export class UserService {
isEmailVerified: true, isEmailVerified: true,
}); });
await this.customerService.createGuardianCustomer(user.id, {
firstName,
lastName,
countryOfResidence: CountryIso.SAUDI_ARABIA,
});
return this.findUserOrThrow({ id: user.id }); 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}`); this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`);
const user = await this.userRepository.createUser({ const user = await this.userRepository.createUser({
appleId, appleId,
@ -201,6 +208,11 @@ export class UserService {
isEmailVerified: true, isEmailVerified: true,
}); });
await this.customerService.createGuardianCustomer(user.id, {
firstName,
lastName,
countryOfResidence: CountryIso.SAUDI_ARABIA,
});
return this.findUserOrThrow({ id: user.id }); return this.findUserOrThrow({ id: user.id });
} }
@ -213,6 +225,15 @@ export class UserService {
return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true }); 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) { private sendCheckerAccountCreatedEmail(email: string, token: string) {
return this.notificationsService.sendEmailAsync({ return this.notificationsService.sendEmailAsync({
to: email, to: email,