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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user