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

@@ -15,6 +15,8 @@ describe('SendNotificationHandler', () => {
};
let mockEmailService: { send: ReturnType<typeof vi.fn> };
let mockFcmService: { send: ReturnType<typeof vi.fn> };
let mockStringeeSmsService: { sendNotification: ReturnType<typeof vi.fn>; isAvailable: boolean };
let mockZaloOaService: { sendMessage: ReturnType<typeof vi.fn>; isAvailable: boolean };
let mockTemplateService: { render: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
@@ -46,6 +48,14 @@ describe('SendNotificationHandler', () => {
};
mockEmailService = { send: vi.fn().mockResolvedValue({ messageId: 'msg-1' }) };
mockFcmService = { send: vi.fn().mockResolvedValue('fcm-msg-1') };
mockStringeeSmsService = {
sendNotification: vi.fn().mockResolvedValue({ messageId: 'sms-msg-1' }),
isAvailable: true,
};
mockZaloOaService = {
sendMessage: vi.fn().mockResolvedValue({ messageId: 'zalo-msg-1' }),
isAvailable: false,
};
mockTemplateService = {
render: vi.fn().mockReturnValue({ subject: 'Chào mừng!', body: '<p>Chào mừng!</p>' }),
};
@@ -57,6 +67,8 @@ describe('SendNotificationHandler', () => {
mockPreferenceRepo as any,
mockEmailService as any,
mockFcmService as any,
mockStringeeSmsService as any,
mockZaloOaService as any,
mockTemplateService as any,
mockEventBus as any,
mockLogger as any,
@@ -97,6 +109,52 @@ describe('SendNotificationHandler', () => {
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT');
});
it('sends SMS notification via Stringee successfully', async () => {
const command = new SendNotificationCommand(
'user-1', 'SMS', 'user.registered', { phone: '0901234567', role: 'BUYER' }, '+84901234567',
);
await handler.execute(command);
expect(mockStringeeSmsService.sendNotification).toHaveBeenCalledWith({
to: '+84901234567',
message: 'Chào mừng!', // HTML stripped
});
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT');
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('logs pending when Stringee SMS is not available', async () => {
mockStringeeSmsService.isAvailable = false;
const command = new SendNotificationCommand(
'user-1', 'SMS', 'user.registered', {}, '+84901234567',
);
await handler.execute(command);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Stringee is not configured'),
'SendNotificationHandler',
);
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
expect(mockStringeeSmsService.sendNotification).not.toHaveBeenCalled();
});
it('marks notification as FAILED when SMS send throws', async () => {
mockStringeeSmsService.sendNotification.mockRejectedValue(new Error('Stringee API error'));
const command = new SendNotificationCommand(
'user-1', 'SMS', 'user.registered', {}, '+84901234567',
);
await handler.execute(command);
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'FAILED', 'Stringee API error');
expect(mockLogger.error).toHaveBeenCalled();
expect(mockEventBus.publish).not.toHaveBeenCalled();
});
it('skips notification when user preference is disabled', async () => {
mockPreferenceRepo.isEnabled.mockResolvedValue(false);
@@ -111,28 +169,19 @@ describe('SendNotificationHandler', () => {
expect(mockLogger.log).toHaveBeenCalled();
});
it('logs pending status for unimplemented channels (SMS)', async () => {
const command = new SendNotificationCommand(
'user-1', 'SMS', 'user.registered', {}, '+84901234567',
);
await handler.execute(command);
expect(mockLogger.warn).toHaveBeenCalled();
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
expect(mockEmailService.send).not.toHaveBeenCalled();
expect(mockFcmService.send).not.toHaveBeenCalled();
});
it('logs pending status for unimplemented channels (ZALO_OA)', async () => {
it('logs pending when Zalo OA is not available', async () => {
const command = new SendNotificationCommand(
'user-1', 'ZALO_OA', 'user.registered', {}, 'zalo-id',
);
await handler.execute(command);
expect(mockLogger.warn).toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Zalo OA is not configured'),
'SendNotificationHandler',
);
expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING');
expect(mockZaloOaService.sendMessage).not.toHaveBeenCalled();
});
it('marks notification as FAILED when email send throws', async () => {

View File

@@ -1,18 +1,21 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DomainException, EventBusService, LoggerService } from '@modules/shared';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type EventBusService, type LoggerService } from '@modules/shared';
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,
INotificationPreferenceRepository,
type INotificationPreferenceRepository,
} from '../../../domain/repositories/notification-preference.repository';
import {
NOTIFICATION_REPOSITORY,
INotificationRepository,
type INotificationRepository,
} from '../../../domain/repositories/notification.repository';
import { EmailService } from '../../../infrastructure/services/email.service';
import { FcmService } from '../../../infrastructure/services/fcm.service';
import { TemplateService } from '../../../infrastructure/services/template.service';
import { type EmailService } from '../../../infrastructure/services/email.service';
import { type FcmService } from '../../../infrastructure/services/fcm.service';
import { type StringeeSmsService } from '../../../infrastructure/services/stringee-sms.service';
import { type TemplateService } from '../../../infrastructure/services/template.service';
import { type ZaloOaService } from '../../../infrastructure/services/zalo-oa.service';
import { getZaloZnsTemplates } from '../../../infrastructure/services/zalo-zns-templates';
import { SendNotificationCommand } from './send-notification.command';
@CommandHandler(SendNotificationCommand)
@@ -24,6 +27,8 @@ export class SendNotificationHandler implements ICommandHandler<SendNotification
private readonly preferenceRepo: INotificationPreferenceRepository,
private readonly emailService: EmailService,
private readonly fcmService: FcmService,
private readonly stringeeSmsService: StringeeSmsService,
private readonly zaloOaService: ZaloOaService,
private readonly templateService: TemplateService,
private readonly eventBus: EventBusService,
private readonly logger: LoggerService,
@@ -75,14 +80,49 @@ export class SendNotificationHandler implements ICommandHandler<SendNotification
break;
case 'SMS':
case 'ZALO_OA':
// Placeholder — these channels will be implemented when providers are integrated
this.logger.warn(
`Channel ${channel} not yet implemented — notification logged but not sent`,
'SendNotificationHandler',
);
await this.notificationRepo.updateStatus(notification.id, 'PENDING');
return;
if (!this.stringeeSmsService.isAvailable) {
this.logger.warn(
'SMS channel requested but Stringee is not configured — notification logged as PENDING',
'SendNotificationHandler',
);
await this.notificationRepo.updateStatus(notification.id, 'PENDING');
return;
}
await this.stringeeSmsService.sendNotification({
to: recipientAddress,
message: rendered.body.replace(/<[^>]*>/g, ''), // Strip HTML for SMS
});
break;
case 'ZALO_OA': {
if (!this.zaloOaService.isAvailable) {
this.logger.warn(
'ZALO_OA channel requested but Zalo OA is not configured — notification logged as PENDING',
'SendNotificationHandler',
);
await this.notificationRepo.updateStatus(notification.id, 'PENDING');
return;
}
const znsTemplates = getZaloZnsTemplates();
const znsTpl = znsTemplates[templateKey];
if (!znsTpl) {
this.logger.warn(
`No ZNS template mapped for "${templateKey}" — Zalo OA notification logged as PENDING`,
'SendNotificationHandler',
);
await this.notificationRepo.updateStatus(notification.id, 'PENDING');
return;
}
await this.zaloOaService.sendMessage({
toUid: recipientAddress,
templateId: znsTpl.templateId,
templateData: znsTpl.mapParams(templateData),
});
break;
}
}
await this.notificationRepo.updateStatus(notification.id, 'SENT');

View File

@@ -0,0 +1,221 @@
import { StringeeSmsService } from '../services/stringee-sms.service';
describe('StringeeSmsService', () => {
let service: StringeeSmsService;
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
service = new StringeeSmsService(mockLogger as any);
vi.restoreAllMocks();
});
afterEach(() => {
delete process.env['STRINGEE_API_KEY'];
delete process.env['STRINGEE_BRANDNAME'];
});
describe('onModuleInit', () => {
it('initializes when STRINGEE_API_KEY is set', () => {
process.env['STRINGEE_API_KEY'] = 'test-api-key';
process.env['STRINGEE_BRANDNAME'] = 'TestBrand';
service.onModuleInit();
expect(service.isAvailable).toBe(true);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('TestBrand'),
'StringeeSmsService',
);
});
it('disables when STRINGEE_API_KEY is not set', () => {
service.onModuleInit();
expect(service.isAvailable).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('STRINGEE_API_KEY not set'),
'StringeeSmsService',
);
});
it('defaults brandname to GoodGo when not specified', () => {
process.env['STRINGEE_API_KEY'] = 'test-api-key';
service.onModuleInit();
expect(service.isAvailable).toBe(true);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('GoodGo'),
'StringeeSmsService',
);
});
});
describe('sendNotification', () => {
beforeEach(() => {
process.env['STRINGEE_API_KEY'] = 'test-api-key';
service.onModuleInit();
});
it('sends SMS successfully', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-123' }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.sendNotification({
to: '0901234567',
message: 'Hello from GoodGo',
});
expect(result).toEqual({ messageId: 'msg-123' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://api.stringee.com/v1/sms',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-STRINGEE-AUTH': 'test-api-key',
}),
}),
);
});
it('normalizes 0-prefixed phone numbers to +84', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-456' }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await service.sendNotification({ to: '0901234567', message: 'Test' });
const callBody = JSON.parse(
(globalThis.fetch as any).mock.calls[0][1].body,
);
expect(callBody.to[0].number).toBe('+84901234567');
});
it('normalizes +84 prefixed numbers correctly', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-789' }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await service.sendNotification({ to: '+84901234567', message: 'Test' });
const callBody = JSON.parse(
(globalThis.fetch as any).mock.calls[0][1].body,
);
expect(callBody.to[0].number).toBe('+84901234567');
});
it('retries on failure with exponential backoff', async () => {
const mockFailResponse = {
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server error'),
};
const mockSuccessResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'msg-retry' }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockFailResponse as any)
.mockResolvedValueOnce(mockSuccessResponse as any);
const result = await service.sendNotification({
to: '0901234567',
message: 'Retry test',
});
expect(result).toEqual({ messageId: 'msg-retry' });
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('attempt 1/3 failed'),
'StringeeSmsService',
);
});
it('throws after 3 failed attempts', async () => {
const mockFailResponse = {
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server error'),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFailResponse as any);
await expect(
service.sendNotification({ to: '0901234567', message: 'Fail test' }),
).rejects.toThrow('Stringee API error (500)');
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('failed after 3 attempts'),
'StringeeSmsService',
);
});
it('throws when Stringee returns non-zero result code', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ r: -1, message: 'Invalid number' }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await expect(
service.sendNotification({ to: '0901234567', message: 'Error test' }),
).rejects.toThrow('Stringee SMS rejected');
});
it('throws when not initialized', async () => {
const uninitService = new StringeeSmsService(mockLogger as any);
await expect(
uninitService.sendNotification({ to: '0901234567', message: 'Test' }),
).rejects.toThrow('Stringee SMS not initialized');
});
});
describe('sendOTP', () => {
beforeEach(() => {
process.env['STRINGEE_API_KEY'] = 'test-api-key';
process.env['STRINGEE_BRANDNAME'] = 'GoodGo';
service.onModuleInit();
});
it('sends OTP with formatted message', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ r: 0, message_id: 'otp-123' }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.sendOTP({ to: '0901234567', code: '123456' });
expect(result).toEqual({ messageId: 'otp-123' });
const callBody = JSON.parse(
(globalThis.fetch as any).mock.calls[0][1].body,
);
expect(callBody.text).toContain('123456');
expect(callBody.text).toContain('GoodGo');
expect(callBody.text).toContain('5 phut');
});
});
});

View File

@@ -0,0 +1,262 @@
import { ZaloOaService } from '../services/zalo-oa.service';
describe('ZaloOaService', () => {
let service: ZaloOaService;
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
service = new ZaloOaService(mockLogger as any);
vi.restoreAllMocks();
});
afterEach(() => {
delete process.env['ZALO_OA_ID'];
delete process.env['ZALO_OA_ACCESS_TOKEN'];
});
describe('onModuleInit', () => {
it('initializes when ZALO_OA_ID and ZALO_OA_ACCESS_TOKEN are set', () => {
process.env['ZALO_OA_ID'] = 'test-oa-id';
process.env['ZALO_OA_ACCESS_TOKEN'] = 'test-access-token';
service.onModuleInit();
expect(service.isAvailable).toBe(true);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('test-oa-id'),
'ZaloOaService',
);
});
it('disables when ZALO_OA_ID is not set', () => {
process.env['ZALO_OA_ACCESS_TOKEN'] = 'test-access-token';
service.onModuleInit();
expect(service.isAvailable).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set'),
'ZaloOaService',
);
});
it('disables when ZALO_OA_ACCESS_TOKEN is not set', () => {
process.env['ZALO_OA_ID'] = 'test-oa-id';
service.onModuleInit();
expect(service.isAvailable).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('ZALO_OA_ID or ZALO_OA_ACCESS_TOKEN not set'),
'ZaloOaService',
);
});
it('disables when neither var is set', () => {
service.onModuleInit();
expect(service.isAvailable).toBe(false);
expect(mockLogger.warn).toHaveBeenCalled();
});
});
describe('sendMessage', () => {
beforeEach(() => {
process.env['ZALO_OA_ID'] = 'test-oa-id';
process.env['ZALO_OA_ACCESS_TOKEN'] = 'test-access-token';
service.onModuleInit();
});
it('sends a template message successfully', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
error: 0,
message: 'Success',
data: { msg_id: 'zalo-msg-123' },
}),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.sendMessage({
toUid: '1234567890',
templateId: 'tpl-inquiry-001',
templateData: { buyer_name: 'Nguyễn Văn A', listing_title: 'Căn hộ Q7' },
});
expect(result).toEqual({ messageId: 'zalo-msg-123' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://business.openapi.zalo.me/message/template',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
access_token: 'test-access-token',
}),
}),
);
});
it('sends correct request body shape', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
error: 0,
data: { msg_id: 'zalo-msg-456' },
}),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await service.sendMessage({
toUid: '9876543210',
templateId: 'tpl-payment-001',
templateData: { amount: '50000000', payment_id: 'PAY-001' },
});
const callBody = JSON.parse(
(globalThis.fetch as any).mock.calls[0][1].body,
);
expect(callBody).toEqual({
phone: '9876543210',
template_id: 'tpl-payment-001',
template_data: { amount: '50000000', payment_id: 'PAY-001' },
});
});
it('retries on failure with exponential backoff', async () => {
const mockFailResponse = {
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server error'),
};
const mockSuccessResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
error: 0,
data: { msg_id: 'zalo-msg-retry' },
}),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockFailResponse as any)
.mockResolvedValueOnce(mockSuccessResponse as any);
const result = await service.sendMessage({
toUid: '1234567890',
templateId: 'tpl-001',
templateData: { key: 'value' },
});
expect(result).toEqual({ messageId: 'zalo-msg-retry' });
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('attempt 1/3 failed'),
'ZaloOaService',
);
});
it('throws after 3 failed attempts', async () => {
const mockFailResponse = {
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server error'),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFailResponse as any);
await expect(
service.sendMessage({
toUid: '1234567890',
templateId: 'tpl-001',
templateData: { key: 'value' },
}),
).rejects.toThrow('Zalo OA API error (500)');
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('failed after 3 attempts'),
'ZaloOaService',
);
});
it('throws when Zalo returns non-zero error code', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
error: -201,
message: 'Invalid template',
}),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await expect(
service.sendMessage({
toUid: '1234567890',
templateId: 'invalid-tpl',
templateData: {},
}),
).rejects.toThrow('Zalo OA message rejected');
});
it('throws when not initialized', async () => {
const uninitService = new ZaloOaService(mockLogger as any);
await expect(
uninitService.sendMessage({
toUid: '1234567890',
templateId: 'tpl-001',
templateData: {},
}),
).rejects.toThrow('Zalo OA not initialized');
});
it('generates a fallback message ID when API does not return one', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ error: 0, data: {} }),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
const result = await service.sendMessage({
toUid: '1234567890',
templateId: 'tpl-001',
templateData: {},
});
expect(result.messageId).toMatch(/^zalo-oa-\d+$/);
});
it('masks recipient UID in log output', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
error: 0,
data: { msg_id: 'zalo-msg-mask' },
}),
text: vi.fn(),
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as any);
await service.sendMessage({
toUid: '1234567890',
templateId: 'tpl-001',
templateData: {},
});
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('123456***'),
'ZaloOaService',
);
});
});
});

View File

@@ -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));
}
}

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));
}
}

View File

@@ -0,0 +1,87 @@
/**
* Zalo OA ZNS template configuration.
*
* Maps internal notification template keys to ZNS template IDs registered in
* the Zalo OA Manager console. Template IDs must be configured via environment
* variables (ZALO_ZNS_TEMPLATE_*).
*
* Template parameters must match the ZNS template definitions exactly —
* see the Zalo OA Manager for the approved parameter names.
*/
export interface ZaloZnsTemplateConfig {
/** ZNS template ID (registered in Zalo OA Manager) */
templateId: string;
/** Map our internal template data keys to ZNS parameter names */
mapParams: (data: Record<string, unknown>) => Record<string, string>;
}
/**
* Returns the ZNS template configurations, reading template IDs from environment.
* Returns only templates where the env var is configured.
*/
export function getZaloZnsTemplates(): Record<string, ZaloZnsTemplateConfig> {
const templates: Record<string, ZaloZnsTemplateConfig> = {};
// Inquiry received — notify property owner/agent about a new inquiry
const inquiryTplId = process.env['ZALO_ZNS_TEMPLATE_INQUIRY'] ?? '';
if (inquiryTplId) {
templates['inquiry.received'] = {
templateId: inquiryTplId,
mapParams: (data) => ({
customer_name: String(data['senderName'] ?? ''),
property_name: String(data['listingTitle'] ?? ''),
message: String(data['message'] ?? ''),
}),
};
}
// Payment confirmed — notify buyer about successful payment
const paymentTplId = process.env['ZALO_ZNS_TEMPLATE_PAYMENT'] ?? '';
if (paymentTplId) {
templates['payment.confirmed'] = {
templateId: paymentTplId,
mapParams: (data) => ({
payment_id: String(data['paymentId'] ?? ''),
amount: String(data['amountVND'] ?? ''),
payment_method: String(data['provider'] ?? ''),
}),
};
}
// Listing approved — notify owner that listing is live
const listingApprovedTplId = process.env['ZALO_ZNS_TEMPLATE_LISTING_APPROVED'] ?? '';
if (listingApprovedTplId) {
templates['listing.approved'] = {
templateId: listingApprovedTplId,
mapParams: (data) => ({
listing_title: String(data['listingTitle'] ?? ''),
}),
};
}
// Listing rejected — notify owner that listing was rejected
const listingRejectedTplId = process.env['ZALO_ZNS_TEMPLATE_LISTING_REJECTED'] ?? '';
if (listingRejectedTplId) {
templates['listing.rejected'] = {
templateId: listingRejectedTplId,
mapParams: (data) => ({
listing_title: String(data['listingTitle'] ?? ''),
reason: String(data['reason'] ?? ''),
}),
};
}
// Listing sold — notify owner that listing is sold
const listingSoldTplId = process.env['ZALO_ZNS_TEMPLATE_LISTING_SOLD'] ?? '';
if (listingSoldTplId) {
templates['listing.sold'] = {
templateId: listingSoldTplId,
mapParams: (data) => ({
listing_title: String(data['listingTitle'] ?? ''),
}),
};
}
return templates;
}

View File

@@ -13,14 +13,15 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger';
import { NotificationChannel as PrismaChannel } from '@prisma/client';
import { IsBoolean, IsEnum, IsString } from 'class-validator';
import { CurrentUser, JwtPayload } from '@modules/auth';
import { CurrentUser, type JwtPayload } from '@modules/auth';
import {
NOTIFICATION_REPOSITORY,
INotificationRepository,
type INotificationRepository,
NOTIFICATION_PREFERENCE_REPOSITORY,
INotificationPreferenceRepository,
type INotificationPreferenceRepository,
} from '../../domain';
import { TemplateService } from '../../infrastructure/services/template.service';
import { type TemplateService } from '../../infrastructure/services/template.service';
import { type NotificationsGateway } from '../gateways/notifications.gateway';
class UpdatePreferenceDto {
@ApiProperty({ enum: PrismaChannel, description: 'Notification channel' })
@@ -47,6 +48,7 @@ export class NotificationsController {
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY)
private readonly preferenceRepo: INotificationPreferenceRepository,
private readonly templateService: TemplateService,
private readonly notificationsGateway: NotificationsGateway,
) {}
@Get('history')
@@ -80,6 +82,15 @@ export class NotificationsController {
return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled);
}
@Get('unread-count')
@ApiOperation({ summary: 'Get unread notification count (Redis-cached)' })
@ApiResponse({ status: 200, description: 'Unread count retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUnreadCount(@CurrentUser() user: JwtPayload) {
const count = await this.notificationRepo.countUnreadByUserId(user.sub);
return { unreadCount: count };
}
@Get('unread')
@ApiOperation({ summary: 'Get unread notifications' })
@ApiResponse({ status: 200, description: 'Unread notifications retrieved' })
@@ -105,6 +116,9 @@ export class NotificationsController {
@Param('id') id: string,
) {
await this.notificationRepo.markAsRead(id, user.sub);
// Invalidate cached count and push updated count via WebSocket
await this.notificationsGateway.invalidateUnreadCount(user.sub);
await this.notificationsGateway.emitUnreadCount(user.sub);
return { success: true };
}
@@ -114,6 +128,9 @@ export class NotificationsController {
@ApiResponse({ status: 401, description: 'Unauthorized' })
async markAllAsRead(@CurrentUser() user: JwtPayload) {
const count = await this.notificationRepo.markAllAsRead(user.sub);
// Invalidate cached count and push updated count via WebSocket
await this.notificationsGateway.invalidateUnreadCount(user.sub);
await this.notificationsGateway.emitUnreadCount(user.sub);
return { markedCount: count };
}

View File

@@ -0,0 +1,272 @@
import { Inject } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
WebSocketGateway,
WebSocketServer,
type OnGatewayConnection,
type OnGatewayDisconnect,
type OnGatewayInit,
} from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
import { TokenService, type JwtPayload } from '@modules/auth';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
import { LoggerService, RedisService } from '@modules/shared';
import type { NotificationSentEvent } from '../../domain/events/notification-sent.event';
import {
NOTIFICATION_REPOSITORY,
type INotificationRepository,
} from '../../domain/repositories/notification.repository';
/** Redis key for the per-user unread notification counter. */
const UNREAD_COUNT_KEY = (userId: string) => `notifications:unread:${userId}`;
/** TTL for the cached unread count (1 hour). */
const UNREAD_COUNT_TTL = 3600;
@WebSocketGateway({
namespace: '/notifications',
cors: {
origin: (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000')
.split(',')
.map((o) => o.trim()),
credentials: true,
},
})
export class NotificationsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server!: Server;
/** Track connected sockets per user for multi-device support. */
private readonly userSockets = new Map<string, Set<string>>();
constructor(
private readonly tokenService: TokenService,
private readonly logger: LoggerService,
private readonly redisService: RedisService,
@Inject(NOTIFICATION_REPOSITORY)
private readonly notificationRepo: INotificationRepository,
) {}
afterInit(): void {
this.logger.log('NotificationsGateway initialized', 'NotificationsGateway');
}
/* ────────────────────────────────────────────
* Connection lifecycle
* ──────────────────────────────────────────── */
async handleConnection(client: Socket): Promise<void> {
try {
const payload = this.extractAndVerifyToken(client);
if (!payload) {
client.disconnect(true);
return;
}
// Attach identity to the socket for later use
client.data['userId'] = payload.sub;
client.data['role'] = payload.role;
// Join the user's private room
await client.join(`user:${payload.sub}`);
// Track socket for bookkeeping
if (!this.userSockets.has(payload.sub)) {
this.userSockets.set(payload.sub, new Set());
}
this.userSockets.get(payload.sub)!.add(client.id);
// Push the current unread count on connect
const unreadCount = await this.getUnreadCount(payload.sub);
client.emit('notification:unread-count', { unreadCount });
this.logger.debug(
`WS connected: user=${payload.sub} socket=${client.id}`,
'NotificationsGateway',
);
} catch (error) {
this.logger.error(
`WS connection error: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
'NotificationsGateway',
);
client.disconnect(true);
}
}
handleDisconnect(client: Socket): void {
const userId = client.data['userId'] as string | undefined;
if (userId) {
const sockets = this.userSockets.get(userId);
if (sockets) {
sockets.delete(client.id);
if (sockets.size === 0) {
this.userSockets.delete(userId);
}
}
}
this.logger.debug(
`WS disconnected: user=${userId ?? 'unknown'} socket=${client.id}`,
'NotificationsGateway',
);
}
/* ────────────────────────────────────────────
* Domain event handlers
* ──────────────────────────────────────────── */
/**
* Listens to the `notification.sent` domain event emitted by
* {@link SendNotificationHandler} after a notification is persisted & sent.
*
* Pushes `notification:new` to the user's room and bumps the
* cached unread counter.
*/
@OnEvent('notification.sent', { async: true })
async handleNotificationSent(event: NotificationSentEvent): Promise<void> {
try {
this.server.to(`user:${event.userId}`).emit('notification:new', {
id: event.aggregateId,
templateKey: event.templateKey,
channel: event.channel,
occurredAt: event.occurredAt.toISOString(),
});
// Increment cached unread count
await this.incrementUnreadCount(event.userId);
// Also emit updated count
const unreadCount = await this.getUnreadCount(event.userId);
this.server
.to(`user:${event.userId}`)
.emit('notification:unread-count', { unreadCount });
} catch (error) {
this.logger.error(
`Failed to emit WS notification for user ${event.userId}: ${
error instanceof Error ? error.message : error
}`,
error instanceof Error ? error.stack : undefined,
'NotificationsGateway',
);
}
}
/* ────────────────────────────────────────────
* Public helpers — used by the controller
* ──────────────────────────────────────────── */
/**
* Emit an updated unread count to a user after they mark
* notifications as read (called from the controller).
*/
async emitUnreadCount(userId: string): Promise<void> {
const unreadCount = await this.getUnreadCount(userId);
this.server
.to(`user:${userId}`)
.emit('notification:unread-count', { unreadCount });
}
/**
* Invalidate the cached unread count (called after mark-as-read).
*/
async invalidateUnreadCount(userId: string): Promise<void> {
if (this.redisService.isAvailable()) {
await this.redisService.del(UNREAD_COUNT_KEY(userId));
}
}
/* ────────────────────────────────────────────
* Private helpers
* ──────────────────────────────────────────── */
/**
* Extract JWT from the socket handshake and verify it.
*
* Supports three sources (in priority order):
* 1. `handshake.auth.token` — Socket.IO `auth` option (recommended)
* 2. `handshake.headers.authorization` — HTTP upgrade header
* 3. `handshake.query.token` — query string (least secure)
*/
private extractAndVerifyToken(client: Socket): JwtPayload | null {
const raw: unknown =
client.handshake.auth?.['token'] ??
client.handshake.headers?.['authorization'] ??
client.handshake.query?.['token'];
if (!raw || typeof raw !== 'string') {
this.logger.warn(
`WS auth failed: no token provided (socket=${client.id})`,
'NotificationsGateway',
);
return null;
}
const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw;
const payload = this.tokenService.verifyAccessToken(token);
if (!payload) {
this.logger.warn(
`WS auth failed: invalid token (socket=${client.id})`,
'NotificationsGateway',
);
}
return payload;
}
/**
* Read the unread count from Redis (cache-aside pattern).
* Falls back to the database when Redis is unavailable or cache misses.
*/
private async getUnreadCount(userId: string): Promise<number> {
if (this.redisService.isAvailable()) {
try {
const cached = await this.redisService.get(UNREAD_COUNT_KEY(userId));
if (cached !== null) {
return Number(cached);
}
} catch {
// Redis unavailable — fall through to DB
}
}
const count = await this.notificationRepo.countUnreadByUserId(userId);
// Warm the cache
if (this.redisService.isAvailable()) {
try {
await this.redisService.set(
UNREAD_COUNT_KEY(userId),
String(count),
UNREAD_COUNT_TTL,
);
} catch {
// Non-critical — continue without cache
}
}
return count;
}
/**
* Increment the cached unread counter in Redis (if available).
* The counter is lazily initialised from the DB on the next read if
* the key does not exist.
*/
private async incrementUnreadCount(userId: string): Promise<void> {
if (!this.redisService.isAvailable()) return;
try {
const client = this.redisService.getClient();
const key = UNREAD_COUNT_KEY(userId);
const exists = await client.exists(key);
if (exists) {
await client.incr(key);
}
// If key doesn't exist, getUnreadCount will populate it on next read
} catch {
// Non-critical
}
}
}