diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index db25c10..0f99f01 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -16,9 +16,14 @@ import { AuthService } from './auth.service'; import { CurrentUser } from './decorators/current-user.decorator'; import { Public } from './decorators/public.decorator'; 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 { 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 { VerifySalonOtpDto } from './dto/verify-salon-otp.dto'; import { AuthUser } from './interfaces/auth-user.interface'; @ApiTags('Auth') @@ -27,6 +32,35 @@ import { AuthUser } from './interfaces/auth-user.interface'; export class AuthController { 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() @ApiPublicEndpoint('Request OTP code via SMS') @ApiStandardMessageResponse('OTP sent') @@ -37,7 +71,9 @@ export class AuthController { } @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) @Post('otp/verify') verifyOtp(@Body() dto: VerifyOtpDto) { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index dde5581..a82dc55 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthorizationModule } from '../authorization/authorization.module'; import { Customer } from '../customers/entities/customer.entity'; +import { Salon } from '../salons/entities/salon.entity'; import { SmsModule } from '../sms/sms.module'; import { UsersModule } from '../users/users.module'; import { AuthController } from './auth.controller'; @@ -17,7 +18,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; @Module({ imports: [ - TypeOrmModule.forFeature([Otp, RefreshToken, Customer]), + TypeOrmModule.forFeature([Otp, RefreshToken, Customer, Salon]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 119eb52..e4ff4b7 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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, @InjectRepository(Customer) private readonly customersRepository: Repository, + @InjectRepository(Salon) + private readonly salonsRepository: Repository, private readonly usersService: UsersService, private readonly smsService: SmsService, private readonly jwtService: JwtService, @@ -63,6 +73,11 @@ export class AuthService { 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, @@ -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 { + 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' }, @@ -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 { + private async consumeOtp(mobile: string, code: string): Promise { 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 { + 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 registerSalonOwner( + dto: VerifySalonOtpDto, + ): Promise { + 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 = [ + '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 { + 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 { + private async createUserFromOtp(dto: VerifyOtpDto): Promise { 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; } diff --git a/src/auth/dto/check-salon-mobile-response.dto.ts b/src/auth/dto/check-salon-mobile-response.dto.ts new file mode 100644 index 0000000..5c24c08 --- /dev/null +++ b/src/auth/dto/check-salon-mobile-response.dto.ts @@ -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; +} diff --git a/src/auth/dto/check-salon-mobile.dto.ts b/src/auth/dto/check-salon-mobile.dto.ts new file mode 100644 index 0000000..0ca5b8a --- /dev/null +++ b/src/auth/dto/check-salon-mobile.dto.ts @@ -0,0 +1,6 @@ +import { Matches } from 'class-validator'; + +export class CheckSalonMobileDto { + @Matches(/^09\d{9}$/) + mobile: string; +} diff --git a/src/auth/dto/request-salon-otp-response.dto.ts b/src/auth/dto/request-salon-otp-response.dto.ts new file mode 100644 index 0000000..e54b390 --- /dev/null +++ b/src/auth/dto/request-salon-otp-response.dto.ts @@ -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; +} diff --git a/src/auth/dto/salon-auth-response.dto.ts b/src/auth/dto/salon-auth-response.dto.ts new file mode 100644 index 0000000..fbf8827 --- /dev/null +++ b/src/auth/dto/salon-auth-response.dto.ts @@ -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; +} diff --git a/src/auth/dto/verify-otp.dto.ts b/src/auth/dto/verify-otp.dto.ts index 2c87218..cd42942 100644 --- a/src/auth/dto/verify-otp.dto.ts +++ b/src/auth/dto/verify-otp.dto.ts @@ -1,10 +1,14 @@ import { IsDateString, + IsIn, IsOptional, IsString, Length, Matches, } 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 { @Matches(/^09\d{9}$/) @@ -32,4 +36,8 @@ export class VerifyOtpDto { @IsString() @Length(1, 500) address?: string; + + @IsOptional() + @IsIn(OTP_SIGNUP_ROLES) + role?: (typeof OTP_SIGNUP_ROLES)[number]; } diff --git a/src/auth/dto/verify-salon-otp.dto.ts b/src/auth/dto/verify-salon-otp.dto.ts new file mode 100644 index 0000000..06751c0 --- /dev/null +++ b/src/auth/dto/verify-salon-otp.dto.ts @@ -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; +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index ad4ce0e..ce6a71a 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -17,6 +17,8 @@ export default () => { 'https://api.sms-webservice.com/api/V3', OTP_LENGTH: process.env.OTP_LENGTH ?? '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_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60', OTP_DEBUG: process.env.OTP_DEBUG ?? 'false',