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