feat(auth): add MizbanSMS OTP provider with per-flow messages and signup recovery
- Add MizbanSMS as selectable SMS provider (SMS_PROVIDER), keep Kavenegar - Distinct OTP wording per flow via OtpMessageKind (register/login/change-phone) - register() resumes an unverified account instead of blocking re-registration - Hourly cleanup of abandoned unverified accounts (>24h) + expired OTP codes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ 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 {
|
||||
@@ -39,26 +40,46 @@ export class AuthService {
|
||||
if (!phone) {
|
||||
throw new BadRequestException('Invalid mobile number');
|
||||
}
|
||||
if (await this.usersService.findByPhone(phone)) {
|
||||
|
||||
// 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 && (await this.usersService.findByEmail(email))) {
|
||||
throw new ConflictException('Email already in use');
|
||||
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);
|
||||
const user = await this.usersService.create({
|
||||
phone,
|
||||
email,
|
||||
firstName: registerDto.firstName,
|
||||
lastName: registerDto.lastName,
|
||||
password: hashedPassword,
|
||||
phoneVerified: false,
|
||||
});
|
||||
|
||||
const { destination } = await this.verificationService.issueLoginOtp(user);
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -87,7 +108,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (!user.phoneVerified) {
|
||||
const { destination } = await this.verificationService.issueLoginOtp(user);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user