feat(notifications): add multi-channel notification module with Email, FCM, templates, and event listeners

- Domain: NotificationLog/NotificationPreference entities, repositories, channel value object
- Infrastructure: EmailService (nodemailer/SMTP), FcmService (firebase-admin), TemplateService (Handlebars)
- Application: SendNotification CQRS command, UserRegistered + AgentVerified event listeners
- Presentation: NotificationsController with history, preferences, and templates endpoints
- Prisma: NotificationLog and NotificationPreference models with proper indexes
- Templates: Vietnamese notification templates for user.registered, agent.verified, listing.approved, inquiry.received, password.reset

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:42:17 +07:00
parent 9301f44119
commit 0b29fac35e
42 changed files with 1720 additions and 6 deletions

View File

@@ -0,0 +1,63 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import * as nodemailer from 'nodemailer';
export interface SendEmailDto {
to: string;
subject: string;
html: string;
}
@Injectable()
export class EmailService implements OnModuleInit {
private transporter!: nodemailer.Transporter;
constructor(private readonly logger: LoggerService) {}
onModuleInit(): void {
const host = process.env['SMTP_HOST'] ?? 'localhost';
const port = Number(process.env['SMTP_PORT'] ?? '1025');
const user = process.env['SMTP_USER'];
const pass = process.env['SMTP_PASS'];
this.transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
...(user && pass ? { auth: { user, pass } } : {}),
});
this.logger.log(`Email transport configured: ${host}:${port}`, 'EmailService');
}
async send(dto: SendEmailDto): Promise<{ messageId: string }> {
const from = process.env['SMTP_FROM'] ?? 'noreply@goodgo.vn';
try {
const info = await this.transporter.sendMail({
from,
to: dto.to,
subject: dto.subject,
html: dto.html,
});
this.logger.log(`Email sent to ${dto.to}: ${info.messageId}`, 'EmailService');
return { messageId: info.messageId };
} catch (error) {
this.logger.error(
`Failed to send email to ${dto.to}: ${error instanceof Error ? error.message : String(error)}`,
'EmailService',
);
throw error;
}
}
async verify(): Promise<boolean> {
try {
await this.transporter.verify();
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,74 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import * as admin from 'firebase-admin';
export interface SendPushDto {
token: string;
title: string;
body: string;
data?: Record<string, string>;
}
@Injectable()
export class FcmService implements OnModuleInit {
private initialized = false;
constructor(private readonly logger: LoggerService) {}
onModuleInit(): void {
const serviceAccountJson = process.env['FIREBASE_SERVICE_ACCOUNT'];
if (!serviceAccountJson) {
this.logger.warn(
'FIREBASE_SERVICE_ACCOUNT not set — push notifications disabled',
'FcmService',
);
return;
}
try {
const serviceAccount = JSON.parse(serviceAccountJson) as admin.ServiceAccount;
if (!admin.apps.length) {
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
}
this.initialized = true;
this.logger.log('Firebase Admin initialized for push notifications', 'FcmService');
} catch (error) {
this.logger.error(
`Failed to initialize Firebase: ${error instanceof Error ? error.message : String(error)}`,
'FcmService',
);
}
}
get isAvailable(): boolean {
return this.initialized;
}
async send(dto: SendPushDto): Promise<string> {
if (!this.initialized) {
throw new Error('FCM not initialized — FIREBASE_SERVICE_ACCOUNT not configured');
}
try {
const messageId = await admin.messaging().send({
token: dto.token,
notification: {
title: dto.title,
body: dto.body,
},
data: dto.data,
android: { priority: 'high' },
apns: { payload: { aps: { sound: 'default' } } },
});
this.logger.log(`Push sent to token ${dto.token.slice(0, 10)}...: ${messageId}`, 'FcmService');
return messageId;
} catch (error) {
this.logger.error(
`Failed to send push: ${error instanceof Error ? error.message : String(error)}`,
'FcmService',
);
throw error;
}
}
}

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import Handlebars from 'handlebars';
export interface TemplateDefinition {
subject: string;
body: string;
}
export interface RenderedTemplate {
subject: string;
body: string;
}
const TEMPLATES: Record<string, TemplateDefinition> = {
'user.registered': {
subject: 'Chào mừng bạn đến với GoodGo!',
body: `<h1>Xin chào {{phone}}!</h1>
<p>Cảm ơn bạn đã đăng ký tài khoản GoodGo với vai trò <strong>{{role}}</strong>.</p>
<p>Bạn có thể bắt đầu khám phá bất động sản ngay bây giờ.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'agent.verified': {
subject: 'Tài khoản Đại lý đã được xác minh',
body: `<h1>Chúc mừng!</h1>
<p>Tài khoản đại lý của bạn (ID: {{agentId}}) đã được xác minh thành công.</p>
<p>Bạn có thể bắt đầu đăng tin bất động sản ngay bây giờ.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'listing.approved': {
subject: 'Tin đăng đã được duyệt',
body: `<h1>Tin đăng được phê duyệt!</h1>
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn đã được duyệt và hiển thị trên GoodGo.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'inquiry.received': {
subject: 'Bạn có yêu cầu tư vấn mới',
body: `<h1>Yêu cầu tư vấn mới</h1>
<p>Bạn nhận được yêu cầu tư vấn từ <strong>{{senderName}}</strong> cho tin đăng <strong>{{listingTitle}}</strong>.</p>
<p>Nội dung: {{message}}</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'password.reset': {
subject: 'Đặt lại mật khẩu GoodGo',
body: `<h1>Đặt lại mật khẩu</h1>
<p>Bạn đã yêu cầu đặt lại mật khẩu. Sử dụng mã OTP sau: <strong>{{otp}}</strong></p>
<p>Mã có hiệu lực trong {{expiryMinutes}} phút.</p>
<p>Nếu bạn không yêu cầu, hãy bỏ qua email này.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
};
@Injectable()
export class TemplateService {
private compiledTemplates = new Map<string, { subject: Handlebars.TemplateDelegate; body: Handlebars.TemplateDelegate }>();
constructor() {
for (const [key, def] of Object.entries(TEMPLATES)) {
this.compiledTemplates.set(key, {
subject: Handlebars.compile(def.subject),
body: Handlebars.compile(def.body),
});
}
}
render(templateKey: string, data: Record<string, unknown>): RenderedTemplate {
const compiled = this.compiledTemplates.get(templateKey);
if (!compiled) {
throw new Error(`Notification template "${templateKey}" not found`);
}
return {
subject: compiled.subject(data),
body: compiled.body(data),
};
}
hasTemplate(templateKey: string): boolean {
return this.compiledTemplates.has(templateKey);
}
getTemplateKeys(): string[] {
return Array.from(this.compiledTemplates.keys());
}
}