add login flow for waiting list demo app

This commit is contained in:
Abdalhamid Alhamad
2025-03-04 14:42:02 +03:00
parent 54ce5b022d
commit 9b5f863577
16 changed files with 184 additions and 21 deletions

View File

@ -4,7 +4,7 @@ import { Request } from 'express';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiLangRequestHeader } from '~/core/decorators';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
CreateUnverifiedUserRequestDto,
@ -14,9 +14,11 @@ import {
LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto,
VerifyLoginOtpRequestDto,
VerifyOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
@ -43,6 +45,20 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login/otp')
@HttpCode(HttpStatus.NO_CONTENT)
async sendLoginOtp(@Body() data: SendLoginOtpRequestDto) {
return this.authService.sendLoginOtp(data);
}
@Post('login/verify')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async verifyLoginOtp(@Body() data: VerifyLoginOtpRequestDto) {
const [token, user] = await this.authService.verifyLoginOtp(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('register/set-email')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)

View File

@ -5,8 +5,10 @@ export * from './forget-password.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';
export * from './send-login-otp.request.dto';
export * from './set-email.request.dto';
export * from './set-junior-password.request.dto';
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';

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class SendLoginOtpRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { SendLoginOtpRequestDto } from './send-login-otp.request.dto';
export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto {
@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

@ -18,8 +18,10 @@ import {
ForgetPasswordRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
VerifyLoginOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { GrantType, Roles } from '../enums';
@ -325,6 +327,39 @@ export class AuthService {
}
}
async sendLoginOtp({ email }: SendLoginOtpRequestDto) {
const user = await this.userService.findOrCreateByEmail(email);
this.logger.log(`Sending login OTP to ${email}`);
return this.otpService.generateAndSendOtp({
recipient: email,
scope: OtpScope.LOGIN,
otpType: OtpType.EMAIL,
userId: user.id,
});
}
async verifyLoginOtp({ email, otp }: VerifyLoginOtpRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUserOrThrow({ email });
this.logger.log(`Verifying login OTP for ${email}`);
const isOtpValid = await this.otpService.verifyOtp({
otpType: OtpType.EMAIL,
scope: OtpScope.LOGIN,
userId: user.id,
value: otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${email}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
this.logger.log(`Login OTP verified successfully for ${email}`);
const token = await this.generateAuthToken(user);
return [token, user];
}
logout(req: Request) {
this.logger.log('Logging out');
const accessToken = req.headers.authorization?.split(' ')[1] as string;

View File

@ -56,6 +56,8 @@ export class NotificationsService {
scope: NotificationScope.USER_INVITED,
channel: NotificationChannel.EMAIL,
});
console.log('++++++++++++++++++++++++=');
console.log(data);
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification, data.data);
}
@ -71,7 +73,7 @@ export class NotificationsService {
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification);
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification, { otp });
}
private async sendPushNotification(userId: string, title: string, body: string) {

View File

@ -24,6 +24,9 @@ export class Otp {
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('boolean', { default: false, name: 'is_used' })
isUsed!: boolean;
@ManyToOne(() => User, (user) => user.otp, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;

View File

@ -1,4 +1,5 @@
export enum OtpScope {
VERIFY_PHONE = 'VERIFY_PHONE',
FORGET_PASSWORD = 'FORGET_PASSWORD',
LOGIN = 'LOGIN',
}

View File

@ -14,7 +14,7 @@ export class OtpRepository {
createOtp(otp: Partial<Otp>) {
return this.otpRepository.save(
this.otpRepository.create({
userId: otp.userId,
userId: otp.userId ?? undefined,
value: otp.value,
scope: otp.scope,
otpType: otp.otpType,
@ -31,10 +31,15 @@ export class OtpRepository {
value: otp.value,
otpType: otp.otpType,
expiresAt: MoreThan(new Date()),
isUsed: false,
},
order: {
createdAt: 'DESC',
},
});
}
updateOtp(id: string, data: Partial<Otp>) {
return this.otpRepository.update(id, data);
}
}

View File

@ -35,11 +35,16 @@ export class OtpService {
if (!otp) {
this.logger.error(
`OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType}`,
`OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType} or used`,
);
return false;
}
const { affected } = await this.otpRepository.updateOtp(otp.id, { isUsed: true });
console.log('+++++++++++++++++++++++++++');
console.log(affected);
this.logger.log(`OTP verified successfully for ${verifyOtpRequest.userId}`);
return !!otp;
}

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { IsAbove18 } from '~/core/decorators/validations';
import { Gender } from '~/customer/enums';
@ -16,7 +16,8 @@ export class CreateCustomerRequestDto {
@ApiProperty({ example: 'MALE' })
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
gender!: Gender;
@IsOptional()
gender?: Gender;
@ApiProperty({ example: 'JO' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) })
@ -31,39 +32,47 @@ export class CreateCustomerRequestDto {
@ApiProperty({ example: '999300024' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.nationalId' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.nationalId' }) })
nationalId!: string;
@IsOptional()
nationalId?: string;
@ApiProperty({ example: '2021-01-01' })
@IsDateString(
{},
{ message: i18n('validation.IsDateString', { path: 'general', property: 'junior.nationalIdExpiry' }) },
)
nationalIdExpiry!: Date;
@IsOptional()
nationalIdExpiry?: Date;
@ApiProperty({ example: 'Employee' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.sourceOfIncome' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.sourceOfIncome' }) })
sourceOfIncome!: string;
@IsOptional()
sourceOfIncome?: string;
@ApiProperty({ example: 'Accountant' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.profession' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.profession' }) })
profession!: string;
@IsOptional()
profession?: string;
@ApiProperty({ example: 'Finance' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.professionType' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.professionType' }) })
professionType!: string;
@IsOptional()
professionType?: string;
@ApiProperty({ example: false })
@IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'junior.isPep' }) })
isPep!: boolean;
@IsOptional()
isPep?: boolean;
@ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdFrontId' }) })
civilIdFrontId!: string;
@IsOptional()
civilIdFrontId?: string;
@ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdBackId' }) })
civilIdBackId!: string;
@IsOptional()
civilIdBackId?: string;
}

View File

@ -87,17 +87,17 @@ export class Customer extends BaseEntity {
@OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true })
guardian!: Guardian;
@Column('uuid', { name: 'civil_id_front_id' })
@Column('uuid', { name: 'civil_id_front_id', nullable: true })
civilIdFrontId!: string;
@Column('uuid', { name: 'civil_id_back_id' })
@Column('uuid', { name: 'civil_id_back_id', nullable: true })
civilIdBackId!: string;
@OneToOne(() => Document, (document) => document.customerCivilIdFront)
@OneToOne(() => Document, (document) => document.customerCivilIdFront, { nullable: true })
@JoinColumn({ name: 'civil_id_front_id' })
civilIdFront!: Document;
@OneToOne(() => Document, (document) => document.customerCivilIdBack)
@OneToOne(() => Document, (document) => document.customerCivilIdBack, { nullable: true })
@JoinColumn({ name: 'civil_id_back_id' })
civilIdBack!: Document;

View File

@ -98,7 +98,7 @@ export class CustomerService {
throw new BadRequestException('CUSTOMER.ALRADY_EXISTS');
}
await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId);
// await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId);
const customer = await this.customerRepository.createCustomer(userId, body, true);
this.logger.log(`customer created for user ${userId}`);
@ -166,9 +166,12 @@ export class CustomerService {
throw new BadRequestException('CUSTOMER.CIVIL_ID_NOT_CREATED_BY_USER');
}
const customerWithTheSameId = await this.customerRepository.findCustomerByCivilId(civilIdFrontId, civilIdBackId);
const customerWithTheSameCivilId = await this.customerRepository.findCustomerByCivilId(
civilIdFrontId,
civilIdBackId,
);
if (customerWithTheSameId) {
if (customerWithTheSameCivilId) {
this.logger.error(
`Customer with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`,
);

View File

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUsedFlagToOtpAndRemoveConstraintsFromCustomers1741087742821 implements MigrationInterface {
name = 'AddUsedFlagToOtpAndRemoveConstraintsFromCustomers1741087742821';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "otp" ADD "is_used" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_front_id" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_back_id" DROP NOT NULL`);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_back_id" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "civil_id_front_id" SET NOT NULL`);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(`ALTER TABLE "otp" DROP COLUMN "is_used"`);
}
}

View File

@ -22,3 +22,4 @@ export * from './1736753223884-add_created_by_to_document_table';
export * from './1739868002943-add-kyc-status-to-customer';
export * from './1739954239949-add-civilid-to-customers-and-update-notifications-settings';
export * from './1740045960580-create-user-registration-table';
export * from './1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers';

View File

@ -97,6 +97,25 @@ export class UserService {
return user;
}
async findOrCreateByEmail(email: string) {
this.logger.log(`Finding or creating user with email ${email} `);
const user = await this.userRepository.findOne({ email });
if (!user) {
this.logger.log(`User with email ${email} not found, creating new user`);
return this.userRepository.createUser({ email, roles: [Roles.GUARDIAN] });
}
if (user && user.roles.includes(Roles.JUNIOR)) {
this.logger.error(`User with email ${email} 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 email ${email} found successfully`);
return user;
}
async createUser(data: Partial<User>) {
this.logger.log(`Creating user with data ${JSON.stringify(data)}`);
const user = await this.userRepository.createUser(data);