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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user