change flow

This commit is contained in:
2026-07-03 12:25:44 +03:30
parent 1f06b378e6
commit 713734b842
10 changed files with 287 additions and 20 deletions
+153 -18
View File
@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Injectable,
@@ -12,11 +13,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { Repository, IsNull } from 'typeorm';
import { Customer } from '../customers/entities/customer.entity';
import { Salon } from '../salons/entities/salon.entity';
import { SmsService } from '../sms/sms.service';
import { UserRole } from '../users/enums/user-role.enum';
import { User } from '../users/entities/user.entity';
import { UsersService } from '../users/users.service';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { VerifySalonOtpDto } from './dto/verify-salon-otp.dto';
import { Otp } from './entities/otp.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { JwtPayload } from './interfaces/jwt-payload.interface';
@@ -33,11 +36,16 @@ export interface AuthTokensResponse {
};
}
export interface SalonAuthResponse extends AuthTokensResponse {
salon?: Salon;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly otpLength: number;
private readonly otpExpiresInMinutes: number;
private readonly otpSalonSignupExpiresInMinutes: number;
private readonly otpMaxAttempts: number;
private readonly otpCooldownSeconds: number;
private readonly otpDebug: boolean;
@@ -50,6 +58,8 @@ export class AuthService {
private readonly refreshTokenRepository: Repository<RefreshToken>,
@InjectRepository(Customer)
private readonly customersRepository: Repository<Customer>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
private readonly usersService: UsersService,
private readonly smsService: SmsService,
private readonly jwtService: JwtService,
@@ -63,6 +73,11 @@ export class AuthService {
this.configService.get<string>('OTP_EXPIRES_IN_MINUTES') ?? '5',
10,
);
this.otpSalonSignupExpiresInMinutes = parseInt(
this.configService.get<string>('OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES') ??
'15',
10,
);
this.otpMaxAttempts = parseInt(
this.configService.get<string>('OTP_MAX_ATTEMPTS') ?? '5',
10,
@@ -77,6 +92,60 @@ export class AuthService {
}
async requestOtp(mobile: string): Promise<{ message: string }> {
await this.sendOtp(mobile, this.otpExpiresInMinutes);
return { message: 'OTP sent' };
}
async checkSalonMobile(
mobile: string,
): Promise<{ isRegistered: boolean }> {
await this.ensureSalonMobileCanProceed(mobile);
const isRegistered = await this.isSalonOwnerRegistered(mobile);
return { isRegistered };
}
async requestSalonOtp(mobile: string): Promise<{
message: string;
isRegistered: boolean;
expiresInSeconds: number;
}> {
await this.ensureSalonMobileCanProceed(mobile);
const isRegistered = await this.isSalonOwnerRegistered(mobile);
const expiresInMinutes = isRegistered
? this.otpExpiresInMinutes
: this.otpSalonSignupExpiresInMinutes;
await this.sendOtp(mobile, expiresInMinutes);
return {
message: 'OTP sent',
isRegistered,
expiresInSeconds: expiresInMinutes * 60,
};
}
async verifySalonOtp(dto: VerifySalonOtpDto): Promise<SalonAuthResponse> {
await this.consumeOtp(dto.mobile, dto.code);
const existingUser = await this.usersService.findByMobile(dto.mobile);
if (existingUser) {
if (existingUser.role !== UserRole.SALON) {
throw new ConflictException(
'This mobile is already registered with another account type',
);
}
return this.issueTokens(existingUser);
}
return this.registerSalonOwner(dto);
}
private async sendOtp(
mobile: string,
expiresInMinutes: number,
): Promise<void> {
const recentOtp = await this.otpRepository.findOne({
where: { mobile, verified: false },
order: { createdAt: 'DESC' },
@@ -97,9 +166,7 @@ export class AuthService {
const code = this.generateOtpCode();
const hashedCode = await bcrypt.hash(code, 10);
const expiresAt = new Date(
Date.now() + this.otpExpiresInMinutes * 60 * 1000,
);
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
await this.otpRepository.save({
mobile,
@@ -119,13 +186,11 @@ export class AuthService {
) {
this.logger.debug(`OTP for ${mobile}: ${code}`);
}
return { message: 'OTP sent' };
}
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
private async consumeOtp(mobile: string, code: string): Promise<void> {
const otp = await this.otpRepository.findOne({
where: { mobile: dto.mobile, verified: false },
where: { mobile, verified: false },
order: { createdAt: 'DESC' },
});
@@ -141,7 +206,7 @@ export class AuthService {
throw new UnauthorizedException('Too many failed attempts');
}
const isValid = await bcrypt.compare(dto.code, otp.code);
const isValid = await bcrypt.compare(code, otp.code);
if (!isValid) {
otp.attempts += 1;
await this.otpRepository.save(otp);
@@ -150,6 +215,67 @@ export class AuthService {
otp.verified = true;
await this.otpRepository.save(otp);
}
private async isSalonOwnerRegistered(mobile: string): Promise<boolean> {
const user = await this.usersService.findByMobile(mobile);
return user?.role === UserRole.SALON;
}
private async ensureSalonMobileCanProceed(mobile: string): Promise<void> {
const user = await this.usersService.findByMobile(mobile);
if (user && user.role !== UserRole.SALON) {
throw new ConflictException(
'This mobile is already registered with another account type',
);
}
}
private async registerSalonOwner(
dto: VerifySalonOtpDto,
): Promise<SalonAuthResponse> {
this.validateSalonSignupFields(dto);
const user = await this.usersService.create({
firstName: dto.firstName!,
lastName: dto.lastName!,
mobile: dto.mobile,
role: UserRole.SALON,
});
const salon = await this.salonsRepository.save({
name: dto.salonName!,
description: dto.salonDescription ?? null,
address: dto.salonAddress!,
phone: dto.salonPhone!,
ownerId: user.id,
});
const tokens = await this.issueTokens(user);
return { ...tokens, salon };
}
private validateSalonSignupFields(dto: VerifySalonOtpDto): void {
const requiredFields: Array<keyof VerifySalonOtpDto> = [
'firstName',
'lastName',
'salonName',
'salonAddress',
'salonPhone',
];
const missingFields = requiredFields.filter((field) => !dto[field]);
if (missingFields.length > 0) {
throw new BadRequestException(
`Missing required fields for salon registration: ${missingFields.join(', ')}`,
);
}
}
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
await this.consumeOtp(dto.mobile, dto.code);
const user = await this.resolveUserForLogin(dto);
return this.issueTokens(user);
@@ -205,27 +331,36 @@ export class AuthService {
return existingUser;
}
return this.createCustomerUser(dto);
if (!dto.role) {
throw new BadRequestException(
'role is required when registering a new account',
);
}
return this.createUserFromOtp(dto);
}
private async createCustomerUser(dto: VerifyOtpDto): Promise<User> {
private async createUserFromOtp(dto: VerifyOtpDto): Promise<User> {
const firstName = dto.firstName ?? 'کاربر';
const lastName = dto.lastName ?? dto.mobile.slice(-4);
const dateOfBirth = dto.dateOfBirth ?? '2000-01-01';
const address = dto.address ?? '-';
const user = await this.usersService.create({
firstName,
lastName,
mobile: dto.mobile,
role: UserRole.CUSTOMER,
role: dto.role!,
});
await this.customersRepository.save({
userId: user.id,
dateOfBirth,
address,
});
if (dto.role === UserRole.CUSTOMER) {
const dateOfBirth = dto.dateOfBirth ?? '2000-01-01';
const address = dto.address ?? '-';
await this.customersRepository.save({
userId: user.id,
dateOfBirth,
address,
});
}
return user;
}