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,11 @@
|
||||
import { type NotificationChannel } from '../../../domain/value-objects/notification-channel.vo';
|
||||
|
||||
export class SendNotificationCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly channel: NotificationChannel,
|
||||
public readonly templateKey: string,
|
||||
public readonly templateData: Record<string, unknown>,
|
||||
public readonly recipientAddress: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { EventBusService } from '@modules/shared/infrastructure/event-bus.service';
|
||||
import { SendNotificationCommand } from './send-notification.command';
|
||||
import {
|
||||
NOTIFICATION_REPOSITORY,
|
||||
type INotificationRepository,
|
||||
} from '../../../domain/repositories/notification.repository';
|
||||
import {
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
type INotificationPreferenceRepository,
|
||||
} from '../../../domain/repositories/notification-preference.repository';
|
||||
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
|
||||
import { EmailService } from '../../../infrastructure/services/email.service';
|
||||
import { FcmService } from '../../../infrastructure/services/fcm.service';
|
||||
import { TemplateService } from '../../../infrastructure/services/template.service';
|
||||
|
||||
@CommandHandler(SendNotificationCommand)
|
||||
export class SendNotificationHandler implements ICommandHandler<SendNotificationCommand> {
|
||||
constructor(
|
||||
@Inject(NOTIFICATION_REPOSITORY)
|
||||
private readonly notificationRepo: INotificationRepository,
|
||||
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY)
|
||||
private readonly preferenceRepo: INotificationPreferenceRepository,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly fcmService: FcmService,
|
||||
private readonly templateService: TemplateService,
|
||||
private readonly eventBus: EventBusService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: SendNotificationCommand): Promise<void> {
|
||||
const { userId, channel, templateKey, templateData, recipientAddress } = command;
|
||||
|
||||
// Check user preference
|
||||
const isEnabled = await this.preferenceRepo.isEnabled(userId, channel, templateKey);
|
||||
if (!isEnabled) {
|
||||
this.logger.log(
|
||||
`Notification skipped: user ${userId} disabled ${channel}/${templateKey}`,
|
||||
'SendNotificationHandler',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render template
|
||||
const rendered = this.templateService.render(templateKey, templateData);
|
||||
|
||||
// Persist notification log
|
||||
const notification = await this.notificationRepo.create({
|
||||
userId,
|
||||
channel,
|
||||
templateKey,
|
||||
subject: rendered.subject,
|
||||
body: rendered.body,
|
||||
metadata: templateData,
|
||||
});
|
||||
|
||||
try {
|
||||
switch (channel) {
|
||||
case 'EMAIL':
|
||||
await this.emailService.send({
|
||||
to: recipientAddress,
|
||||
subject: rendered.subject,
|
||||
html: rendered.body,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'PUSH':
|
||||
await this.fcmService.send({
|
||||
token: recipientAddress,
|
||||
title: rendered.subject,
|
||||
body: rendered.body.replace(/<[^>]*>/g, ''), // Strip HTML for push
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
await this.notificationRepo.updateStatus(notification.id, 'SENT');
|
||||
this.eventBus.publish(new NotificationSentEvent(notification.id, userId, channel, templateKey));
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
await this.notificationRepo.updateStatus(notification.id, 'FAILED', errorMsg);
|
||||
this.logger.error(
|
||||
`Notification ${notification.id} failed on ${channel}: ${errorMsg}`,
|
||||
'SendNotificationHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { AgentVerifiedEvent } from '@modules/auth/domain/events/agent-verified.event';
|
||||
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
|
||||
|
||||
@Injectable()
|
||||
export class AgentVerifiedListener {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('agent.verified', { async: true })
|
||||
async handle(event: AgentVerifiedEvent): Promise<void> {
|
||||
this.logger.log(`Handling agent.verified for ${event.aggregateId}`, 'AgentVerifiedListener');
|
||||
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
include: { user: { select: { id: true, email: true } } },
|
||||
});
|
||||
|
||||
if (!agent?.user.email) return;
|
||||
|
||||
await this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
agent.user.id,
|
||||
'EMAIL',
|
||||
'agent.verified',
|
||||
{ agentId: event.aggregateId },
|
||||
agent.user.email,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { UserRegisteredEvent } from '@modules/auth/domain/events/user-registered.event';
|
||||
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
|
||||
|
||||
@Injectable()
|
||||
export class UserRegisteredListener {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('user.registered', { async: true })
|
||||
async handle(event: UserRegisteredEvent): Promise<void> {
|
||||
this.logger.log(`Handling user.registered for ${event.aggregateId}`, 'UserRegisteredListener');
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
select: { email: true, phone: true },
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
// Send welcome email if user has email
|
||||
if (user.email) {
|
||||
await this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
event.aggregateId,
|
||||
'EMAIL',
|
||||
'user.registered',
|
||||
{ phone: event.phone, role: event.role },
|
||||
user.email,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||
|
||||
export interface NotificationPreferenceEntity {
|
||||
id: string;
|
||||
userId: string;
|
||||
channel: NotificationChannel;
|
||||
eventType: string;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||
|
||||
export type NotificationStatus = 'PENDING' | 'SENT' | 'FAILED' | 'DELIVERED';
|
||||
|
||||
export interface NotificationEntity {
|
||||
id: string;
|
||||
userId: string;
|
||||
channel: NotificationChannel;
|
||||
templateKey: string;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
status: NotificationStatus;
|
||||
errorDetail: string | null;
|
||||
sentAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||
|
||||
export class NotificationSentEvent implements DomainEvent {
|
||||
readonly eventName = 'notification.sent';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly userId: string,
|
||||
public readonly channel: NotificationChannel,
|
||||
public readonly templateKey: string,
|
||||
) {}
|
||||
}
|
||||
16
apps/api/src/modules/notifications/domain/index.ts
Normal file
16
apps/api/src/modules/notifications/domain/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type { NotificationEntity, NotificationStatus } from './entities/notification.entity';
|
||||
export type { NotificationPreferenceEntity } from './entities/notification-preference.entity';
|
||||
export { NotificationSentEvent } from './events/notification-sent.event';
|
||||
export {
|
||||
NOTIFICATION_REPOSITORY,
|
||||
type INotificationRepository,
|
||||
type CreateNotificationDto,
|
||||
} from './repositories/notification.repository';
|
||||
export {
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
type INotificationPreferenceRepository,
|
||||
} from './repositories/notification-preference.repository';
|
||||
export {
|
||||
NotificationChannel,
|
||||
ALL_CHANNELS,
|
||||
} from './value-objects/notification-channel.vo';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type NotificationPreferenceEntity } from '../entities/notification-preference.entity';
|
||||
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||
|
||||
export const NOTIFICATION_PREFERENCE_REPOSITORY = Symbol('NOTIFICATION_PREFERENCE_REPOSITORY');
|
||||
|
||||
export interface INotificationPreferenceRepository {
|
||||
findByUserId(userId: string): Promise<NotificationPreferenceEntity[]>;
|
||||
isEnabled(userId: string, channel: NotificationChannel, eventType: string): Promise<boolean>;
|
||||
upsert(
|
||||
userId: string,
|
||||
channel: NotificationChannel,
|
||||
eventType: string,
|
||||
enabled: boolean,
|
||||
): Promise<NotificationPreferenceEntity>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type NotificationEntity, type NotificationStatus } from '../entities/notification.entity';
|
||||
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
|
||||
|
||||
export const NOTIFICATION_REPOSITORY = Symbol('NOTIFICATION_REPOSITORY');
|
||||
|
||||
export interface CreateNotificationDto {
|
||||
userId: string;
|
||||
channel: NotificationChannel;
|
||||
templateKey: string;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface INotificationRepository {
|
||||
create(dto: CreateNotificationDto): Promise<NotificationEntity>;
|
||||
updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise<void>;
|
||||
findByUserId(userId: string, limit?: number): Promise<NotificationEntity[]>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NotificationChannel as PrismaChannel } from '@prisma/client';
|
||||
|
||||
export const NotificationChannel = {
|
||||
EMAIL: 'EMAIL',
|
||||
SMS: 'SMS',
|
||||
PUSH: 'PUSH',
|
||||
ZALO_OA: 'ZALO_OA',
|
||||
} as const;
|
||||
|
||||
export type NotificationChannel = PrismaChannel;
|
||||
|
||||
export const ALL_CHANNELS: NotificationChannel[] = [
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationChannel.SMS,
|
||||
NotificationChannel.PUSH,
|
||||
NotificationChannel.ZALO_OA,
|
||||
];
|
||||
1
apps/api/src/modules/notifications/index.ts
Normal file
1
apps/api/src/modules/notifications/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NotificationsModule } from './notifications.module';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
48
apps/api/src/modules/notifications/notifications.module.ts
Normal file
48
apps/api/src/modules/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
|
||||
// Domain
|
||||
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
||||
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
||||
|
||||
// Infrastructure
|
||||
import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository';
|
||||
import { PrismaNotificationPreferenceRepository } from './infrastructure/repositories/prisma-notification-preference.repository';
|
||||
import { EmailService } from './infrastructure/services/email.service';
|
||||
import { FcmService } from './infrastructure/services/fcm.service';
|
||||
import { TemplateService } from './infrastructure/services/template.service';
|
||||
|
||||
// Application
|
||||
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
||||
import { UserRegisteredListener } from './application/listeners/user-registered.listener';
|
||||
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
|
||||
|
||||
// Presentation
|
||||
import { NotificationsController } from './presentation/controllers/notifications.controller';
|
||||
|
||||
const CommandHandlers = [SendNotificationHandler];
|
||||
|
||||
const EventListeners = [UserRegisteredListener, AgentVerifiedListener];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: NOTIFICATION_REPOSITORY, useClass: PrismaNotificationRepository },
|
||||
{ provide: NOTIFICATION_PREFERENCE_REPOSITORY, useClass: PrismaNotificationPreferenceRepository },
|
||||
|
||||
// Services
|
||||
EmailService,
|
||||
FcmService,
|
||||
TemplateService,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
|
||||
// Event Listeners
|
||||
...EventListeners,
|
||||
],
|
||||
exports: [EmailService, FcmService, TemplateService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators';
|
||||
import { type JwtPayload } from '@modules/auth';
|
||||
import {
|
||||
NOTIFICATION_REPOSITORY,
|
||||
type INotificationRepository,
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
type INotificationPreferenceRepository,
|
||||
} from '../../domain';
|
||||
import { TemplateService } from '../../infrastructure/services/template.service';
|
||||
import { IsBoolean, IsEnum, IsString } from 'class-validator';
|
||||
import { NotificationChannel as PrismaChannel } from '@prisma/client';
|
||||
|
||||
class UpdatePreferenceDto {
|
||||
@IsEnum(PrismaChannel)
|
||||
channel!: PrismaChannel;
|
||||
|
||||
@IsString()
|
||||
eventType!: string;
|
||||
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
@Controller('notifications')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
@Inject(NOTIFICATION_REPOSITORY)
|
||||
private readonly notificationRepo: INotificationRepository,
|
||||
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY)
|
||||
private readonly preferenceRepo: INotificationPreferenceRepository,
|
||||
private readonly templateService: TemplateService,
|
||||
) {}
|
||||
|
||||
@Get('history')
|
||||
async getHistory(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.notificationRepo.findByUserId(user.sub, limit ?? 50);
|
||||
}
|
||||
|
||||
@Get('preferences')
|
||||
async getPreferences(@CurrentUser() user: JwtPayload) {
|
||||
return this.preferenceRepo.findByUserId(user.sub);
|
||||
}
|
||||
|
||||
@Put('preferences')
|
||||
async updatePreference(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePreferenceDto,
|
||||
) {
|
||||
return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
async getTemplates() {
|
||||
return { templates: this.templateService.getTemplateKeys() };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user