feat(notifications): R8.1 Stringee SMS adapter + rate limiting (TEC-2764)
- Add NotificationChannelPort domain port for SMS/transactional channels. - Refactor StringeeSmsService to implement the port; routes OTP template keys through the tighter otp bucket and transactional keys through the wider bucket. - Add SmsRateLimiterService using a Redis sorted-set sliding window with per-minute + per-hour limits per phone; fails open on Redis errors. - Rate-limit violations throw DomainException(TOO_MANY_REQUESTS, 429) with retryAfterSeconds in the details payload. - Cover adapter + rate limiter with unit tests (22 specs); all 148 notifications tests still green. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,9 +1,21 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { HttpStatus, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { DomainException, ErrorCode, type LoggerService } from '@modules/shared';
|
||||
import type {
|
||||
NotificationChannelPort,
|
||||
SendChannelMessageDto,
|
||||
SendChannelMessageResult,
|
||||
} from '../../domain/ports/notification-channel.port';
|
||||
import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo';
|
||||
import {
|
||||
type SmsRateLimitBucket,
|
||||
type SmsRateLimiterService,
|
||||
} from './sms-rate-limiter.service';
|
||||
|
||||
export interface SendSmsDto {
|
||||
to: string;
|
||||
message: string;
|
||||
/** Rate-limit bucket; defaults to `transactional`. OTP flows should pass `otp`. */
|
||||
bucket?: SmsRateLimitBucket;
|
||||
}
|
||||
|
||||
export interface SendOtpDto {
|
||||
@@ -13,15 +25,26 @@ export interface SendOtpDto {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const OTP_TEMPLATE_KEYS = new Set([
|
||||
'user.phone_change_otp',
|
||||
'auth.login_otp',
|
||||
'auth.kyc_otp',
|
||||
'auth.phone_verify_otp',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class StringeeSmsService implements OnModuleInit {
|
||||
export class StringeeSmsService implements OnModuleInit, NotificationChannelPort {
|
||||
readonly channel: NotificationChannel = 'SMS';
|
||||
|
||||
private apiKey = '';
|
||||
private brandName = '';
|
||||
private initialized = false;
|
||||
private readonly baseUrl = 'https://api.stringee.com/v1/sms';
|
||||
|
||||
constructor(private readonly logger: LoggerService) {}
|
||||
constructor(
|
||||
private readonly logger: LoggerService,
|
||||
private readonly rateLimiter: SmsRateLimiterService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.apiKey = process.env['STRINGEE_API_KEY'] ?? '';
|
||||
@@ -46,26 +69,63 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
async sendOTP(dto: SendOtpDto): Promise<{ messageId: string }> {
|
||||
async sendOTP(dto: SendOtpDto): Promise<SendChannelMessageResult> {
|
||||
const message = `[${this.brandName}] Ma xac thuc cua ban la: ${dto.code}. Ma co hieu luc trong 5 phut.`;
|
||||
return this.sendWithRetry({ to: dto.to, message });
|
||||
return this.dispatch({ to: dto.to, message, bucket: 'otp' });
|
||||
}
|
||||
|
||||
async sendNotification(dto: SendSmsDto): Promise<{ messageId: string }> {
|
||||
return this.sendWithRetry(dto);
|
||||
async sendNotification(dto: SendSmsDto): Promise<SendChannelMessageResult> {
|
||||
return this.dispatch(dto);
|
||||
}
|
||||
|
||||
private async sendWithRetry(dto: SendSmsDto): Promise<{ messageId: string }> {
|
||||
async send(dto: SendChannelMessageDto): Promise<SendChannelMessageResult> {
|
||||
const bucket: SmsRateLimitBucket = OTP_TEMPLATE_KEYS.has(dto.templateKey) ? 'otp' : 'transactional';
|
||||
const plainText = this.stripHtml(dto.body);
|
||||
return this.dispatch({ to: dto.recipient, message: plainText, bucket });
|
||||
}
|
||||
|
||||
private async dispatch(dto: SendSmsDto): Promise<SendChannelMessageResult> {
|
||||
if (!this.initialized) {
|
||||
throw new Error('Stringee SMS not initialized — STRINGEE_API_KEY not configured');
|
||||
}
|
||||
|
||||
const phone = this.normalizePhone(dto.to);
|
||||
const bucket: SmsRateLimitBucket = dto.bucket ?? 'transactional';
|
||||
|
||||
await this.enforceRateLimit(phone, bucket);
|
||||
|
||||
return this.sendWithRetry(phone, dto.message);
|
||||
}
|
||||
|
||||
private async enforceRateLimit(phone: string, bucket: SmsRateLimitBucket): Promise<void> {
|
||||
const perMinute = await this.rateLimiter.check(phone, bucket);
|
||||
if (!perMinute.allowed) {
|
||||
throw new DomainException(
|
||||
ErrorCode.TOO_MANY_REQUESTS,
|
||||
`SMS rate limit exceeded. Retry after ${perMinute.retryAfterSeconds}s.`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
{ bucket: perMinute.bucket, retryAfterSeconds: perMinute.retryAfterSeconds },
|
||||
);
|
||||
}
|
||||
|
||||
const hourlyBucket: SmsRateLimitBucket = bucket === 'otp' ? 'otpHourly' : 'transactionalHourly';
|
||||
const perHour = await this.rateLimiter.check(phone, hourlyBucket);
|
||||
if (!perHour.allowed) {
|
||||
throw new DomainException(
|
||||
ErrorCode.TOO_MANY_REQUESTS,
|
||||
`Hourly SMS limit exceeded. Retry after ${perHour.retryAfterSeconds}s.`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
{ bucket: perHour.bucket, retryAfterSeconds: perHour.retryAfterSeconds },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendWithRetry(phone: string, message: string): Promise<SendChannelMessageResult> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await this.send(dto);
|
||||
return result;
|
||||
return await this.postToStringee(phone, message);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -87,13 +147,11 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async send(dto: SendSmsDto): Promise<{ messageId: string }> {
|
||||
const phone = this.normalizePhone(dto.to);
|
||||
|
||||
private async postToStringee(phone: string, message: string): Promise<SendChannelMessageResult> {
|
||||
const body = {
|
||||
from: { type: 'sms', number: this.brandName, alias: this.brandName },
|
||||
to: [{ type: 'sms', number: phone }],
|
||||
text: dto.message,
|
||||
text: message,
|
||||
};
|
||||
|
||||
const response = await fetch(this.baseUrl, {
|
||||
@@ -112,7 +170,6 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
|
||||
const data = (await response.json()) as { message_id?: string; r?: number; message?: string };
|
||||
|
||||
// Stringee returns r=0 on success
|
||||
if (data.r !== undefined && data.r !== 0) {
|
||||
throw new Error(`Stringee SMS rejected (code ${data.r}): ${data.message ?? 'Unknown reason'}`);
|
||||
}
|
||||
@@ -127,10 +184,6 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
return { messageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize VN phone numbers to E.164 format (+84...).
|
||||
* Accepts: 0901234567, +84901234567, 84901234567
|
||||
*/
|
||||
private normalizePhone(phone: string): string {
|
||||
const cleaned = phone.replace(/[\s\-()]/g, '');
|
||||
|
||||
@@ -146,6 +199,10 @@ export class StringeeSmsService implements OnModuleInit {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user