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:
Ho Ngoc Hai
2026-04-16 05:15:04 +07:00
parent c920934fb6
commit d4e100a00c
48 changed files with 1766 additions and 225 deletions

View File

@@ -0,0 +1,149 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { type LoggerService } from '@modules/shared';
export interface SendZaloOaDto {
/** Zalo user ID (follower UID from OA) */
toUid: string;
/** ZNS template ID registered in Zalo OA Manager */
templateId: string;
/** Template parameter key-value pairs */
templateData: Record<string, string>;
}
export interface ZaloOaMessageResult {
messageId: string;
}
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
/**
* Service for sending template-based messages via Zalo Official Account (OA) API v3.
*
* Uses the Zalo Notification Service (ZNS) to deliver transactional messages
* such as new inquiry alerts, payment confirmations, and listing status changes.
*
* Requires ZALO_OA_ACCESS_TOKEN and ZALO_OA_ID to be configured.
*/
@Injectable()
export class ZaloOaService implements OnModuleInit {
private oaId = '';
private accessToken = '';
private initialized = false;
private readonly znsUrl = 'https://business.openapi.zalo.me/message/template';
constructor(private readonly logger: LoggerService) {}
onModuleInit(): void {
this.oaId = process.env['ZALO_OA_ID'] ?? '';
this.accessToken = process.env['ZALO_OA_ACCESS_TOKEN'] ?? '';
if (!this.oaId || !this.accessToken) {
this.logger.warn(
'ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set — Zalo OA notifications disabled',
'ZaloOaService',
);
return;
}
this.initialized = true;
this.logger.log(
`Zalo OA configured for OA ID "${this.oaId}"`,
'ZaloOaService',
);
}
get isAvailable(): boolean {
return this.initialized;
}
/**
* Send a template-based message to a Zalo user via ZNS (Zalo Notification Service).
*
* The user must be a follower of the Official Account, and the template must be
* pre-registered and approved in the Zalo OA Manager console.
*/
async sendMessage(dto: SendZaloOaDto): Promise<ZaloOaMessageResult> {
return this.sendWithRetry(dto);
}
private async sendWithRetry(dto: SendZaloOaDto): Promise<ZaloOaMessageResult> {
if (!this.initialized) {
throw new Error('Zalo OA not initialized — ZALO_OA_ID / ZALO_OA_ACCESS_TOKEN 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(
`Zalo OA attempt ${attempt}/${MAX_RETRIES} failed: ${lastError.message}. Retrying in ${delayMs}ms...`,
'ZaloOaService',
);
await this.delay(delayMs);
}
}
}
this.logger.error(
`Zalo OA message failed after ${MAX_RETRIES} attempts: ${lastError?.message}`,
'ZaloOaService',
);
throw lastError;
}
private async send(dto: SendZaloOaDto): Promise<ZaloOaMessageResult> {
const body = {
phone: dto.toUid,
template_id: dto.templateId,
template_data: dto.templateData,
};
const response = await fetch(this.znsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
access_token: this.accessToken,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Zalo OA API error (${response.status}): ${errorText}`);
}
const data = (await response.json()) as {
error?: number;
message?: string;
data?: { msg_id?: string };
};
// Zalo API returns error=0 on success
if (data.error !== undefined && data.error !== 0) {
throw new Error(
`Zalo OA message rejected (code ${data.error}): ${data.message ?? 'Unknown reason'}`,
);
}
const messageId = data.data?.msg_id ?? `zalo-oa-${Date.now()}`;
this.logger.log(
`Zalo OA message sent to ${dto.toUid.slice(0, 6)}***: ${messageId}`,
'ZaloOaService',
);
return { messageId };
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}