add cooldown notification repeat

This commit is contained in:
lendry
2026-06-25 14:45:01 +03:00
parent 6c63343fc7
commit 9671fe458b
11 changed files with 173 additions and 25 deletions

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
@@ -214,6 +214,30 @@ export class AuthService {
async sendOtp(recipient: string, channel?: string, ipAddress?: string) {
const existingUser = await this.findUserByRecipient(recipient);
const target = this.normalizeOtpTarget(this.resolveOtpTarget(recipient, channel, existingUser));
const cooldownSeconds = await this.settings.getNumber('OTP_RESEND_COOLDOWN_SECONDS', 60);
const resendKey = this.otpResendKey(target);
const ttl = await this.redis.client.ttl(resendKey);
if (ttl > 0) {
throw new HttpException(
{
message: `Запросить новый код можно через ${this.formatOtpCooldown(ttl)}`,
code: 'OTP_RESEND_COOLDOWN',
resendAvailableAt: new Date(Date.now() + ttl * 1000).toISOString()
},
HttpStatus.TOO_MANY_REQUESTS
);
}
await this.prisma.authCode.updateMany({
where: {
target,
purpose: 'passwordless-login',
usedAt: null
},
data: { usedAt: new Date() }
});
const deliveryChannel = target.includes('@') ? 'email' : 'sms';
const expiryMinutes = await this.settings.getNumber('OTP_EXPIRY_MINUTES', 10);
const code = String(randomInt(100000, 999999));
@@ -238,7 +262,9 @@ export class AuthService {
await this.publishOtpPushNotification(existingUser.id, code, ipAddress, 'Вход в аккаунт');
}
await this.redis.client.del(this.otpAttemptKey(target));
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target) };
await this.redis.client.set(resendKey, '1', 'EX', cooldownSeconds);
const resendAvailableAt = new Date(Date.now() + cooldownSeconds * 1000).toISOString();
return { sent: true, expiresAt: expiresAt.toISOString(), maskedTarget: this.maskTarget(target), resendAvailableAt };
}
private async publishOtpPushNotification(userId: string, code: string, ipAddress: string | undefined, action: string) {
@@ -329,6 +355,22 @@ export class AuthService {
return `otp:attempts:passwordless-login:${target.toLowerCase()}`;
}
private otpResendKey(target: string) {
return `otp:resend:passwordless-login:${target.toLowerCase()}`;
}
private formatOtpCooldown(seconds: number) {
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
if (minutes > 0 && rest > 0) {
return `${minutes}:${String(rest).padStart(2, '0')}`;
}
if (minutes > 0) {
return `${minutes} мин`;
}
return `${rest} сек`;
}
private maskTarget(value: string) {
if (value.includes('@')) {
const [name, domain] = value.split('@');