Files
grow-api/src/auth/auth.service.ts
T
2026-07-07 19:44:31 +03:30

527 lines
15 KiB
TypeScript

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<Otp>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepository: Repository<RefreshToken>,
@InjectRepository(Customer)
private readonly customersRepository: Repository<Customer>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
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<string>('OTP_LENGTH') ?? '5',
10,
);
this.otpExpiresInMinutes = parseInt(
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,
);
this.otpCooldownSeconds = parseInt(
this.configService.get<string>('OTP_COOLDOWN_SECONDS') ?? '60',
10,
);
this.refreshExpiresIn =
this.configService.get<string>('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<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<string> {
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<string>('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<void> {
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<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 ensureAdminMobile(mobile: string): Promise<void> {
const user = await this.usersService.findByMobile(mobile);
if (!user || user.role !== UserRole.ADMIN) {
throw new BadRequestException('این شماره ادمین نیست');
}
}
private async registerSalonOwner(
dto: VerifySalonOtpDto,
): Promise<SalonAuthResponse> {
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<keyof VerifySalonOtpDto> = [
'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<void> {
const city = await this.provincesService.findCity(provinceId, cityId);
if (!city) {
throw new BadRequestException('شهر انتخاب‌شده با استان مطابقت ندارد');
}
}
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
await this.consumeOtp(dto.mobile, dto.code);
const user = await this.resolveUserForLogin(dto);
return this.issueTokens(user);
}
async verifyAdminOtp(dto: VerifyAdminOtpDto): Promise<AuthTokensResponse> {
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<AuthTokensResponse> {
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<void> {
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<MeResponse> {
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<User> {
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<User> {
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<AuthTokensResponse> {
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<string> {
const refreshToken = this.jwtService.sign(
{ sub: userId },
{
secret: this.configService.getOrThrow<string>('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<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return new Date(Date.now() + value * multipliers[unit]);
}
}