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
+37 -1
View File
@@ -16,9 +16,14 @@ import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator'; import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { AuthTokensResponseDto } from './dto/auth-tokens-response.dto'; import { AuthTokensResponseDto } from './dto/auth-tokens-response.dto';
import { CheckSalonMobileDto } from './dto/check-salon-mobile.dto';
import { CheckSalonMobileResponseDto } from './dto/check-salon-mobile-response.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RequestOtpDto } from './dto/request-otp.dto'; import { RequestOtpDto } from './dto/request-otp.dto';
import { RequestSalonOtpResponseDto } from './dto/request-salon-otp-response.dto';
import { SalonAuthResponseDto } from './dto/salon-auth-response.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto';
import { VerifySalonOtpDto } from './dto/verify-salon-otp.dto';
import { AuthUser } from './interfaces/auth-user.interface'; import { AuthUser } from './interfaces/auth-user.interface';
@ApiTags('Auth') @ApiTags('Auth')
@@ -27,6 +32,35 @@ import { AuthUser } from './interfaces/auth-user.interface';
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Public()
@ApiPublicEndpoint('Check whether a salon-owner account exists for a mobile')
@ApiStandardOkResponse(CheckSalonMobileResponseDto)
@Post('salon/check-mobile')
checkSalonMobile(@Body() dto: CheckSalonMobileDto) {
return this.authService.checkSalonMobile(dto.mobile);
}
@Public()
@ApiPublicEndpoint(
'Send OTP for salon login/signup. Signup OTP expires later (15m default). Call this after the signup form is filled.',
)
@ApiStandardOkResponse(RequestSalonOtpResponseDto)
@ApiStandardTooManyRequestsResponse()
@Post('salon/otp/request')
requestSalonOtp(@Body() dto: RequestOtpDto) {
return this.authService.requestSalonOtp(dto.mobile);
}
@Public()
@ApiPublicEndpoint(
'Verify salon OTP. Existing owners only need mobile+code. New owners must also send owner and salon fields.',
)
@ApiStandardOkResponse(SalonAuthResponseDto)
@Post('salon/otp/verify')
verifySalonOtp(@Body() dto: VerifySalonOtpDto) {
return this.authService.verifySalonOtp(dto);
}
@Public() @Public()
@ApiPublicEndpoint('Request OTP code via SMS') @ApiPublicEndpoint('Request OTP code via SMS')
@ApiStandardMessageResponse('OTP sent') @ApiStandardMessageResponse('OTP sent')
@@ -37,7 +71,9 @@ export class AuthController {
} }
@Public() @Public()
@ApiPublicEndpoint('Verify OTP and receive access/refresh tokens') @ApiPublicEndpoint(
'Verify OTP and receive access/refresh tokens. New users must send role (customer or salon-owner).',
)
@ApiStandardOkResponse(AuthTokensResponseDto) @ApiStandardOkResponse(AuthTokensResponseDto)
@Post('otp/verify') @Post('otp/verify')
verifyOtp(@Body() dto: VerifyOtpDto) { verifyOtp(@Body() dto: VerifyOtpDto) {
+2 -1
View File
@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { Customer } from '../customers/entities/customer.entity'; import { Customer } from '../customers/entities/customer.entity';
import { Salon } from '../salons/entities/salon.entity';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
@@ -17,7 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Otp, RefreshToken, Customer]), TypeOrmModule.forFeature([Otp, RefreshToken, Customer, Salon]),
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
+148 -13
View File
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
@@ -12,11 +13,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { Repository, IsNull } from 'typeorm'; import { Repository, IsNull } from 'typeorm';
import { Customer } from '../customers/entities/customer.entity'; import { Customer } from '../customers/entities/customer.entity';
import { Salon } from '../salons/entities/salon.entity';
import { SmsService } from '../sms/sms.service'; import { SmsService } from '../sms/sms.service';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { VerifyOtpDto } from './dto/verify-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto';
import { VerifySalonOtpDto } from './dto/verify-salon-otp.dto';
import { Otp } from './entities/otp.entity'; import { Otp } from './entities/otp.entity';
import { RefreshToken } from './entities/refresh-token.entity'; import { RefreshToken } from './entities/refresh-token.entity';
import { JwtPayload } from './interfaces/jwt-payload.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface';
@@ -33,11 +36,16 @@ export interface AuthTokensResponse {
}; };
} }
export interface SalonAuthResponse extends AuthTokensResponse {
salon?: Salon;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
private readonly otpLength: number; private readonly otpLength: number;
private readonly otpExpiresInMinutes: number; private readonly otpExpiresInMinutes: number;
private readonly otpSalonSignupExpiresInMinutes: number;
private readonly otpMaxAttempts: number; private readonly otpMaxAttempts: number;
private readonly otpCooldownSeconds: number; private readonly otpCooldownSeconds: number;
private readonly otpDebug: boolean; private readonly otpDebug: boolean;
@@ -50,6 +58,8 @@ export class AuthService {
private readonly refreshTokenRepository: Repository<RefreshToken>, private readonly refreshTokenRepository: Repository<RefreshToken>,
@InjectRepository(Customer) @InjectRepository(Customer)
private readonly customersRepository: Repository<Customer>, private readonly customersRepository: Repository<Customer>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly smsService: SmsService, private readonly smsService: SmsService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@@ -63,6 +73,11 @@ export class AuthService {
this.configService.get<string>('OTP_EXPIRES_IN_MINUTES') ?? '5', this.configService.get<string>('OTP_EXPIRES_IN_MINUTES') ?? '5',
10, 10,
); );
this.otpSalonSignupExpiresInMinutes = parseInt(
this.configService.get<string>('OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES') ??
'15',
10,
);
this.otpMaxAttempts = parseInt( this.otpMaxAttempts = parseInt(
this.configService.get<string>('OTP_MAX_ATTEMPTS') ?? '5', this.configService.get<string>('OTP_MAX_ATTEMPTS') ?? '5',
10, 10,
@@ -77,6 +92,60 @@ export class AuthService {
} }
async requestOtp(mobile: string): Promise<{ message: string }> { 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({ const recentOtp = await this.otpRepository.findOne({
where: { mobile, verified: false }, where: { mobile, verified: false },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
@@ -97,9 +166,7 @@ export class AuthService {
const code = this.generateOtpCode(); const code = this.generateOtpCode();
const hashedCode = await bcrypt.hash(code, 10); const hashedCode = await bcrypt.hash(code, 10);
const expiresAt = new Date( const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
Date.now() + this.otpExpiresInMinutes * 60 * 1000,
);
await this.otpRepository.save({ await this.otpRepository.save({
mobile, mobile,
@@ -119,13 +186,11 @@ export class AuthService {
) { ) {
this.logger.debug(`OTP for ${mobile}: ${code}`); 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({ const otp = await this.otpRepository.findOne({
where: { mobile: dto.mobile, verified: false }, where: { mobile, verified: false },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
@@ -141,7 +206,7 @@ export class AuthService {
throw new UnauthorizedException('Too many failed attempts'); 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) { if (!isValid) {
otp.attempts += 1; otp.attempts += 1;
await this.otpRepository.save(otp); await this.otpRepository.save(otp);
@@ -150,6 +215,67 @@ export class AuthService {
otp.verified = true; otp.verified = true;
await this.otpRepository.save(otp); 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); const user = await this.resolveUserForLogin(dto);
return this.issueTokens(user); return this.issueTokens(user);
@@ -205,27 +331,36 @@ export class AuthService {
return existingUser; return existingUser;
} }
return this.createCustomerUser(dto); if (!dto.role) {
throw new BadRequestException(
'role is required when registering a new account',
);
} }
private async createCustomerUser(dto: VerifyOtpDto): Promise<User> { return this.createUserFromOtp(dto);
}
private async createUserFromOtp(dto: VerifyOtpDto): Promise<User> {
const firstName = dto.firstName ?? 'کاربر'; const firstName = dto.firstName ?? 'کاربر';
const lastName = dto.lastName ?? dto.mobile.slice(-4); 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({ const user = await this.usersService.create({
firstName, firstName,
lastName, lastName,
mobile: dto.mobile, mobile: dto.mobile,
role: UserRole.CUSTOMER, role: dto.role!,
}); });
if (dto.role === UserRole.CUSTOMER) {
const dateOfBirth = dto.dateOfBirth ?? '2000-01-01';
const address = dto.address ?? '-';
await this.customersRepository.save({ await this.customersRepository.save({
userId: user.id, userId: user.id,
dateOfBirth, dateOfBirth,
address, address,
}); });
}
return user; return user;
} }
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
export class CheckSalonMobileResponseDto {
@ApiProperty({
description: 'Whether a salon-owner account exists for this mobile',
})
isRegistered: boolean;
}
+6
View File
@@ -0,0 +1,6 @@
import { Matches } from 'class-validator';
export class CheckSalonMobileDto {
@Matches(/^09\d{9}$/)
mobile: string;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
export class RequestSalonOtpResponseDto {
@ApiProperty({ example: 'OTP sent' })
message: string;
@ApiProperty({
description: 'Whether a salon-owner account exists for this mobile',
})
isRegistered: boolean;
@ApiProperty({
description: 'OTP validity window in seconds',
example: 900,
})
expiresInSeconds: number;
}
+11
View File
@@ -0,0 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Salon } from '../../salons/entities/salon.entity';
import { AuthTokensResponseDto } from './auth-tokens-response.dto';
export class SalonAuthResponseDto extends AuthTokensResponseDto {
@ApiPropertyOptional({
type: Salon,
description: 'Present only when a new salon account is created',
})
salon?: Salon;
}
+8
View File
@@ -1,10 +1,14 @@
import { import {
IsDateString, IsDateString,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
Length, Length,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
import { UserRole } from '../../users/enums/user-role.enum';
export const OTP_SIGNUP_ROLES = [UserRole.CUSTOMER, UserRole.SALON] as const;
export class VerifyOtpDto { export class VerifyOtpDto {
@Matches(/^09\d{9}$/) @Matches(/^09\d{9}$/)
@@ -32,4 +36,8 @@ export class VerifyOtpDto {
@IsString() @IsString()
@Length(1, 500) @Length(1, 500)
address?: string; address?: string;
@IsOptional()
@IsIn(OTP_SIGNUP_ROLES)
role?: (typeof OTP_SIGNUP_ROLES)[number];
} }
+43
View File
@@ -0,0 +1,43 @@
import {
IsOptional,
IsString,
Length,
Matches,
} from 'class-validator';
export class VerifySalonOtpDto {
@Matches(/^09\d{9}$/)
mobile: string;
@IsString()
@Length(4, 6)
code: string;
@IsOptional()
@IsString()
@Length(1, 100)
firstName?: string;
@IsOptional()
@IsString()
@Length(1, 100)
lastName?: string;
@IsOptional()
@IsString()
@Length(1, 200)
salonName?: string;
@IsOptional()
@IsString()
salonDescription?: string;
@IsOptional()
@IsString()
@Length(1, 500)
salonAddress?: string;
@IsOptional()
@Matches(/^09\d{9}$/)
salonPhone?: string;
}
+2
View File
@@ -17,6 +17,8 @@ export default () => {
'https://api.sms-webservice.com/api/V3', 'https://api.sms-webservice.com/api/V3',
OTP_LENGTH: process.env.OTP_LENGTH ?? '5', OTP_LENGTH: process.env.OTP_LENGTH ?? '5',
OTP_EXPIRES_IN_MINUTES: process.env.OTP_EXPIRES_IN_MINUTES ?? '5', OTP_EXPIRES_IN_MINUTES: process.env.OTP_EXPIRES_IN_MINUTES ?? '5',
OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES:
process.env.OTP_SALON_SIGNUP_EXPIRES_IN_MINUTES ?? '15',
OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5', OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5',
OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60', OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60',
OTP_DEBUG: process.env.OTP_DEBUG ?? 'false', OTP_DEBUG: process.env.OTP_DEBUG ?? 'false',