import { BadRequestException, ConflictException, HttpException, HttpStatus, Injectable, Logger, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; 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 { ProvincesService } from '../provinces/provinces.service'; import { SmsService } from '../sms/sms.service'; import { SalonSubscription } from '../subscriptions/entities/salon-subscription.entity'; import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; import { UserRole } from '../users/enums/user-role.enum'; import { User } from '../users/entities/user.entity'; import { UsersService } from '../users/users.service'; import { VerifyAdminOtpDto } from './dto/verify-admin-otp.dto'; 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 { AuthUser } from './interfaces/auth-user.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface'; export interface AuthTokensResponse { accessToken: string; refreshToken: string; user: { id: string; firstName: string; lastName: string; mobile: string; role: UserRole; }; } export interface SalonAuthResponse extends AuthTokensResponse { salon?: Salon; } export interface MeResponse { user: AuthTokensResponse['user']; salons?: Salon[]; activeSubscription?: SalonSubscription | null; } @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 refreshExpiresIn: string; constructor( @InjectRepository(Otp) private readonly otpRepository: Repository, @InjectRepository(RefreshToken) private readonly refreshTokenRepository: Repository, @InjectRepository(Customer) private readonly customersRepository: Repository, @InjectRepository(Salon) private readonly salonsRepository: Repository, private readonly provincesService: ProvincesService, private readonly usersService: UsersService, private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly smsService: SmsService, private readonly jwtService: JwtService, private readonly configService: ConfigService, ) { this.otpLength = parseInt( this.configService.get('OTP_LENGTH') ?? '5', 10, ); this.otpExpiresInMinutes = parseInt( this.configService.get('OTP_EXPIRES_IN_MINUTES') ?? '5', 10, ); this.otpSalonSignupExpiresInMinutes = parseInt( this.configService.get('OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES') ?? '15', 10, ); this.otpMaxAttempts = parseInt( this.configService.get('OTP_MAX_ATTEMPTS') ?? '5', 10, ); this.otpCooldownSeconds = parseInt( this.configService.get('OTP_COOLDOWN_SECONDS') ?? '60', 10, ); this.refreshExpiresIn = this.configService.get('JWT_REFRESH_EXPIRES_IN') ?? '7d'; } async requestOtp(mobile: string): Promise<{ message: string; code?: string }> { const code = await this.sendOtp(mobile, this.otpExpiresInMinutes); return this.buildOtpRequestResponse(code); } async requestAdminOtp( mobile: string, ): Promise<{ message: string; code?: string }> { await this.ensureAdminMobile(mobile); return this.requestOtp(mobile); } 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; code?: string; }> { await this.ensureSalonMobileCanProceed(mobile); const isRegistered = await this.isSalonOwnerRegistered(mobile); const expiresInMinutes = isRegistered ? this.otpExpiresInMinutes : this.otpSalonSignupExpiresInMinutes; const code = await this.sendOtp(mobile, expiresInMinutes); return { ...this.buildOtpRequestResponse(code), isRegistered, expiresInSeconds: expiresInMinutes * 60, }; } async verifySalonOtp(dto: VerifySalonOtpDto): Promise { 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 { const recentOtp = await this.otpRepository.findOne({ where: { mobile, verified: false }, order: { createdAt: 'DESC' }, }); if (recentOtp) { const cooldownEnd = recentOtp.createdAt.getTime() + this.otpCooldownSeconds * 1000; if (Date.now() < cooldownEnd) { throw new HttpException( 'Please wait before requesting a new OTP', HttpStatus.TOO_MANY_REQUESTS, ); } } await this.otpRepository.delete({ mobile, verified: false }); const code = this.generateOtpCode(); const hashedCode = await bcrypt.hash(code, 10); const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000); await this.otpRepository.save({ mobile, code: hashedCode, expiresAt, }); const sent = await this.smsService.sendSms(mobile, `کد ورود: ${code}`); if (!sent && !this.shouldExposeOtpInResponse()) { throw new BadRequestException('Failed to send OTP'); } if (this.shouldExposeOtpInResponse()) { this.logger.debug(`OTP for ${mobile}: ${code}`); } return code; } private shouldExposeOtpInResponse(): boolean { return this.configService.get('NODE_ENV') !== 'production'; } private buildOtpRequestResponse(code: string): { message: string; code?: string; } { return { message: 'OTP sent', ...(this.shouldExposeOtpInResponse() ? { code } : {}), }; } private async consumeOtp(mobile: string, code: string): Promise { const otp = await this.otpRepository.findOne({ where: { mobile, verified: false }, order: { createdAt: 'DESC' }, }); if (!otp) { throw new UnauthorizedException('Invalid or expired OTP'); } if (otp.expiresAt < new Date()) { throw new UnauthorizedException('Invalid or expired OTP'); } if (otp.attempts >= this.otpMaxAttempts) { throw new UnauthorizedException('Too many failed attempts'); } const isValid = await bcrypt.compare(code, otp.code); if (!isValid) { otp.attempts += 1; await this.otpRepository.save(otp); throw new UnauthorizedException('Invalid or expired OTP'); } otp.verified = true; await this.otpRepository.save(otp); } private async isSalonOwnerRegistered(mobile: string): Promise { const user = await this.usersService.findByMobile(mobile); return user?.role === UserRole.SALON; } private async ensureSalonMobileCanProceed(mobile: string): Promise { 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 ensureAdminMobile(mobile: string): Promise { const user = await this.usersService.findByMobile(mobile); if (!user || user.role !== UserRole.ADMIN) { throw new BadRequestException('این شماره ادمین نیست'); } } private async registerSalonOwner( dto: VerifySalonOtpDto, ): Promise { this.validateSalonSignupFields(dto); await this.validateSalonLocation(dto.salonProvinceId!, dto.salonCityId!); 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!, provinceId: dto.salonProvinceId!, cityId: dto.salonCityId!, ownerId: user.id, }); const tokens = await this.issueTokens(user); return { ...tokens, salon }; } private validateSalonSignupFields(dto: VerifySalonOtpDto): void { const requiredFields: Array = [ 'firstName', 'lastName', 'salonName', 'salonAddress', 'salonPhone', 'salonProvinceId', 'salonCityId', ]; const missingFields = requiredFields.filter((field) => !dto[field]); if (missingFields.length > 0) { throw new BadRequestException( `Missing required fields for salon registration: ${missingFields.join(', ')}`, ); } } private async validateSalonLocation( provinceId: string, cityId: string, ): Promise { const city = await this.provincesService.findCity(provinceId, cityId); if (!city) { throw new BadRequestException('شهر انتخاب‌شده با استان مطابقت ندارد'); } } async verifyOtp(dto: VerifyOtpDto): Promise { await this.consumeOtp(dto.mobile, dto.code); const user = await this.resolveUserForLogin(dto); return this.issueTokens(user); } async verifyAdminOtp(dto: VerifyAdminOtpDto): Promise { await this.ensureAdminMobile(dto.mobile); await this.consumeOtp(dto.mobile, dto.code); const user = await this.usersService.findByMobile(dto.mobile); if (!user || user.role !== UserRole.ADMIN) { throw new BadRequestException('این شماره ادمین نیست'); } return this.issueTokens(user); } async refresh(refreshToken: string): Promise { const storedTokens = await this.refreshTokenRepository.find({ where: { revokedAt: IsNull() }, relations: { user: true }, }); let matchedToken: RefreshToken | null = null; for (const stored of storedTokens) { if (await bcrypt.compare(refreshToken, stored.tokenHash)) { if (stored.expiresAt < new Date()) { throw new UnauthorizedException('Invalid refresh token'); } matchedToken = stored; break; } } if (!matchedToken) { throw new UnauthorizedException('Invalid refresh token'); } matchedToken.revokedAt = new Date(); await this.refreshTokenRepository.save(matchedToken); return this.issueTokens(matchedToken.user); } async logout(userId: string, refreshToken: string): Promise { const storedTokens = await this.refreshTokenRepository.find({ where: { userId, revokedAt: IsNull() }, }); for (const stored of storedTokens) { if (await bcrypt.compare(refreshToken, stored.tokenHash)) { stored.revokedAt = new Date(); await this.refreshTokenRepository.save(stored); return; } } throw new UnauthorizedException('Invalid refresh token'); } async getMe(authUser: AuthUser): Promise { const user = await this.usersService.findOne(authUser.id); const response: MeResponse = { user: { id: user.id, firstName: user.firstName, lastName: user.lastName, mobile: user.mobile, role: user.role, }, }; if (user.role === UserRole.SALON) { response.salons = await this.salonsRepository.find({ where: { ownerId: user.id }, relations: { province: true, city: true }, order: { createdAt: 'ASC' }, }); response.activeSubscription = await this.salonSubscriptionsService.findActiveSubscriptionForOwner( user.id, ); } return response; } private async resolveUserForLogin(dto: VerifyOtpDto): Promise { const existingUser = await this.usersService.findByMobile(dto.mobile); if (existingUser) { return existingUser; } if (!dto.role) { throw new BadRequestException( 'role is required when registering a new account', ); } return this.createUserFromOtp(dto); } private async createUserFromOtp(dto: VerifyOtpDto): Promise { const firstName = dto.firstName ?? 'کاربر'; const lastName = dto.lastName ?? dto.mobile.slice(-4); const user = await this.usersService.create({ firstName, lastName, mobile: dto.mobile, role: dto.role!, }); 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; } private async issueTokens(user: User): Promise { const payload: JwtPayload = { sub: user.id, role: user.role }; const accessToken = this.jwtService.sign(payload); const refreshToken = await this.createRefreshToken(user.id); return { accessToken, refreshToken, user: { id: user.id, firstName: user.firstName, lastName: user.lastName, mobile: user.mobile, role: user.role, }, }; } private async createRefreshToken(userId: string): Promise { const refreshToken = this.jwtService.sign( { sub: userId }, { secret: this.configService.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: this.refreshExpiresIn as `${number}d`, }, ); const tokenHash = await bcrypt.hash(refreshToken, 10); const expiresAt = this.parseExpiresIn(this.refreshExpiresIn); await this.refreshTokenRepository.save({ userId, tokenHash, expiresAt, }); return refreshToken; } private generateOtpCode(): string { const max = Math.pow(10, this.otpLength); const min = Math.pow(10, this.otpLength - 1); return String(Math.floor(min + Math.random() * (max - min))); } private parseExpiresIn(expiresIn: string): Date { const match = expiresIn.match(/^(\d+)([dhms])$/); if (!match) { return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); } const value = parseInt(match[1], 10); const unit = match[2]; const multipliers: Record = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, }; return new Date(Date.now() + value * multipliers[unit]); } }