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