import { Injectable, type OnModuleInit } from '@nestjs/common'; import { type LoggerService } from '@modules/shared'; export interface SendSmsDto { to: string; message: string; } export interface SendOtpDto { to: string; code: string; } const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; @Injectable() export class StringeeSmsService implements OnModuleInit { private apiKey = ''; private brandName = ''; private initialized = false; private readonly baseUrl = 'https://api.stringee.com/v1/sms'; constructor(private readonly logger: LoggerService) {} 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<{ messageId: string }> { 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 }); } async sendNotification(dto: SendSmsDto): Promise<{ messageId: string }> { return this.sendWithRetry(dto); } private async sendWithRetry(dto: SendSmsDto): Promise<{ messageId: string }> { if (!this.initialized) { throw new Error('Stringee SMS not initialized — STRINGEE_API_KEY not configured'); } let lastError: Error | undefined; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const result = await this.send(dto); return result; } 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 send(dto: SendSmsDto): Promise<{ messageId: string }> { const phone = this.normalizePhone(dto.to); const body = { from: { type: 'sms', number: this.brandName, alias: this.brandName }, to: [{ type: 'sms', number: phone }], text: dto.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 }; // 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'}`); } const messageId = data.message_id ?? `stringee-${Date.now()}`; this.logger.log( `SMS sent to ${phone.slice(0, 6)}***: ${messageId}`, 'StringeeSmsService', ); 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, ''); 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 delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } }