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