import { Injectable, UnauthorizedException, ConflictException, BadRequestException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { UsersService } from '../users/users.service'; import { VerificationService } from '../users/verification.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { User } from '../users/entities/user.entity'; import { normalizeIranMobile } from '../common/phone.util'; import { OtpMessageKind } from '../common/enums'; /** Returned when an action needs phone verification before tokens are issued. */ export interface VerificationRequired { requiresVerification: true; phone: string; // masked } @Injectable() export class AuthService { constructor( private usersService: UsersService, private verificationService: VerificationService, private jwtService: JwtService, private configService: ConfigService, ) {} /** * Create an account from a mobile number. Email is optional contact info only. * The account starts unverified; a LOGIN OTP is sent and must be confirmed via * `verifyOtp` before tokens are issued. */ async register(registerDto: RegisterDto): Promise { const phone = normalizeIranMobile(registerDto.phone); if (!phone) { throw new BadRequestException('Invalid mobile number'); } // An unverified account means a previous signup was never confirmed — let // the user resume it. Only a *verified* phone is a real duplicate. const existing = await this.usersService.findByPhone(phone); if (existing?.phoneVerified) { throw new ConflictException('Mobile number already registered'); } const email = registerDto.email?.trim().toLowerCase() || null; if (email) { const emailOwner = await this.usersService.findByEmail(email); if (emailOwner && emailOwner.id !== existing?.id) { throw new ConflictException('Email already in use'); } } const hashedPassword = await bcrypt.hash(registerDto.password, 12); // Resume the stale record (overwriting name/password/email) instead of // locking the number behind it, or create a fresh account. const user = existing ? await this.usersService.update(existing.id, { email, firstName: registerDto.firstName, lastName: registerDto.lastName, password: hashedPassword, }) : await this.usersService.create({ phone, email, firstName: registerDto.firstName, lastName: registerDto.lastName, password: hashedPassword, phoneVerified: false, }); const { destination } = await this.verificationService.issueLoginOtp( user, OtpMessageKind.REGISTER, ); return { requiresVerification: true, phone: destination }; } /** * Password login by mobile. If the phone isn't verified yet, an OTP is sent and * verification is required to finish. */ async login(loginDto: LoginDto) { const phone = normalizeIranMobile(loginDto.phone); if (!phone) { throw new UnauthorizedException('Invalid credentials'); } const user = await this.usersService.findByPhone(phone); if (!user) { throw new UnauthorizedException('Invalid credentials'); } const isPasswordValid = await bcrypt.compare(loginDto.password, user.password); if (!isPasswordValid) { throw new UnauthorizedException('Invalid credentials'); } if (!user.isActive) { throw new UnauthorizedException('Account is deactivated'); } if (!user.phoneVerified) { // Account exists but never finished signup verification — treat as // registration completion (welcome wording), not a passwordless login. const { destination } = await this.verificationService.issueLoginOtp( user, OtpMessageKind.REGISTER, ); return { requiresVerification: true, phone: destination } as VerificationRequired; } const tokens = await this.generateTokens(user); return { user: this.publicUser(user), ...tokens }; } /** * Passwordless login: send a one-time code to a registered phone. Returns a * generic response regardless of whether the phone exists (no enumeration). */ async requestOtp(rawPhone: string) { const phone = normalizeIranMobile(rawPhone); if (phone) { const user = await this.usersService.findByPhone(phone); if (user && user.isActive) { await this.verificationService.issueLoginOtp(user); } } return { sent: true }; } /** Verify a LOGIN OTP (registration completion or passwordless login). */ async verifyOtp(rawPhone: string, code: string) { const phone = normalizeIranMobile(rawPhone); if (!phone) { throw new BadRequestException('Invalid mobile number'); } const user = await this.usersService.findByPhone(phone); if (!user || !user.isActive) { throw new UnauthorizedException('Invalid credentials'); } await this.verificationService.verifyLoginOtp(user, code); const fresh = (await this.usersService.findById(user.id)) ?? user; const tokens = await this.generateTokens(fresh); return { user: this.publicUser(fresh), ...tokens }; } async refreshToken(refreshToken: string) { try { const payload = this.jwtService.verify(refreshToken, { secret: this.configService.get('jwt.refreshSecret'), }); const user = await this.usersService.findById(payload.sub); if (!user || !user.isActive) { throw new UnauthorizedException(); } return this.generateTokens(user); } catch { throw new UnauthorizedException('Invalid refresh token'); } } private publicUser(user: User) { return { id: user.id, phone: user.phone, email: user.email, phoneVerified: user.phoneVerified, firstName: user.firstName, lastName: user.lastName, role: user.role, }; } private async generateTokens(user: User) { // `email` may be null; use phone as the human-readable identity claim. const payload = { sub: user.id, email: user.email ?? user.phone, role: user.role, }; const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(payload), this.jwtService.signAsync(payload, { secret: this.configService.get('jwt.refreshSecret'), expiresIn: this.configService.get('jwt.refreshExpiresIn'), }), ]); return { accessToken, refreshToken }; } }