Files
goodgo-platform/apps/api/src/modules/notifications/infrastructure/services/stringee-sms.service.ts
Ho Ngoc Hai caa0a58afd 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>
2026-04-18 15:37:45 +07:00

210 lines
6.5 KiB
TypeScript

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 {
to: string;
code: string;
}
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, 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,
private readonly rateLimiter: SmsRateLimiterService,
) {}
onModuleInit(): void {
this.apiKey = process.env['STRINGEE_API_KEY'] ?? '';
this.brandName = process.env['STRINGEE_BRANDNAME'] ?? 'GoodGo';
if (!this.apiKey) {
this.logger.warn(
'STRINGEE_API_KEY not set — SMS notifications disabled',
'StringeeSmsService',
);
return;
}
this.initialized = true;
this.logger.log(
`Stringee SMS configured with brandname "${this.brandName}"`,
'StringeeSmsService',
);
}
get isAvailable(): boolean {
return this.initialized;
}
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.dispatch({ to: dto.to, message, bucket: 'otp' });
}
async sendNotification(dto: SendSmsDto): Promise<SendChannelMessageResult> {
return this.dispatch(dto);
}
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 {
return await this.postToStringee(phone, message);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1);
this.logger.warn(
`Stringee SMS attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`,
'StringeeSmsService',
);
await this.delay(delayMs);
}
}
}
this.logger.error(
`Stringee SMS failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
'StringeeSmsService',
);
throw lastError;
}
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: message,
};
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-STRINGEE-AUTH': this.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Stringee API error (${response.status}): ${errorText}`);
}
const data = (await response.json()) as { message_id?: string; r?: number; message?: string };
if (data.r !== undefined && data.r !== 0) {
throw new Error(`Stringee SMS rejected (code ${data.r}): ${data.message ?? 'Unknown reason'}`);
}
const messageId = data.message_id ?? `stringee-${Date.now()}`;
this.logger.log(
`SMS sent to ${phone.slice(0, 6)}***: ${messageId}`,
'StringeeSmsService',
);
return { messageId };
}
private normalizePhone(phone: string): string {
const cleaned = phone.replace(/[\s\-()]/g, '');
if (cleaned.startsWith('+84')) {
return cleaned;
}
if (cleaned.startsWith('84') && cleaned.length >= 11) {
return `+${cleaned}`;
}
if (cleaned.startsWith('0')) {
return `+84${cleaned.slice(1)}`;
}
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));
}
}