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,5 @@
export { PrismaNotificationRepository } from './repositories/prisma-notification.repository';
export { PrismaNotificationPreferenceRepository } from './repositories/prisma-notification-preference.repository';
export { EmailService, type SendEmailDto } from './services/email.service';
export { FcmService, type SendPushDto } from './services/fcm.service';
export { TemplateService, type RenderedTemplate, type TemplateDefinition } from './services/template.service';

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type INotificationPreferenceRepository } from '../../domain/repositories/notification-preference.repository';
import { type NotificationPreferenceEntity } from '../../domain/entities/notification-preference.entity';
import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo';
@Injectable()
export class PrismaNotificationPreferenceRepository implements INotificationPreferenceRepository {
constructor(private readonly prisma: PrismaService) {}
async findByUserId(userId: string): Promise<NotificationPreferenceEntity[]> {
const records = await this.prisma.notificationPreference.findMany({
where: { userId },
});
return records as NotificationPreferenceEntity[];
}
async isEnabled(userId: string, channel: NotificationChannel, eventType: string): Promise<boolean> {
const pref = await this.prisma.notificationPreference.findUnique({
where: { userId_channel_eventType: { userId, channel, eventType } },
});
// Default to enabled if no preference record exists
return pref?.enabled ?? true;
}
async upsert(
userId: string,
channel: NotificationChannel,
eventType: string,
enabled: boolean,
): Promise<NotificationPreferenceEntity> {
const record = await this.prisma.notificationPreference.upsert({
where: { userId_channel_eventType: { userId, channel, eventType } },
create: { userId, channel, eventType, enabled },
update: { enabled },
});
return record as NotificationPreferenceEntity;
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { type Prisma } from '@prisma/client';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import {
type INotificationRepository,
type CreateNotificationDto,
} from '../../domain/repositories/notification.repository';
import { type NotificationEntity, type NotificationStatus } from '../../domain/entities/notification.entity';
@Injectable()
export class PrismaNotificationRepository implements INotificationRepository {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateNotificationDto): Promise<NotificationEntity> {
const record = await this.prisma.notificationLog.create({
data: {
userId: dto.userId,
channel: dto.channel,
templateKey: dto.templateKey,
subject: dto.subject,
body: dto.body,
metadata: (dto.metadata ?? undefined) as Prisma.InputJsonValue | undefined,
status: 'PENDING',
},
});
return this.toEntity(record);
}
async updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise<void> {
await this.prisma.notificationLog.update({
where: { id },
data: {
status,
errorDetail: errorDetail ?? null,
sentAt: status === 'SENT' || status === 'DELIVERED' ? new Date() : undefined,
},
});
}
async findByUserId(userId: string, limit = 50): Promise<NotificationEntity[]> {
const records = await this.prisma.notificationLog.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
});
return records.map((r) => this.toEntity(r));
}
private toEntity(record: {
id: string;
userId: string;
channel: string;
templateKey: string;
subject: string | null;
body: string;
metadata: unknown;
status: string;
errorDetail: string | null;
sentAt: Date | null;
createdAt: Date;
}): NotificationEntity {
return {
id: record.id,
userId: record.userId,
channel: record.channel as NotificationEntity['channel'],
templateKey: record.templateKey,
subject: record.subject,
body: record.body,
metadata: record.metadata as Record<string, unknown> | null,
status: record.status as NotificationStatus,
errorDetail: record.errorDetail,
sentAt: record.sentAt,
createdAt: record.createdAt,
};
}
}

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