feat(api): add price history, Stringee SMS, Zalo OA, WebSocket notifications, and feature-listing command
- Add PriceHistory model + migration, price-changed domain event, and event handler - Add GetPriceHistory query handler and controller endpoint - Implement StringeeSmsService and ZaloOaService with unit tests - Add Zalo ZNS templates for Vietnamese notification messages - Add WebSocket notification gateway for real-time push - Add FeatureListingCommand for promoted listings - Apply remaining consistent-type-imports lint fixes across API modules Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user