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 { 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 { return this.dispatch(dto); } async send(dto: SendChannelMessageDto): Promise { 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 { 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 { 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 { 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 { 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 { return new Promise((resolve) => setTimeout(resolve, ms)); } }