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,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,
) {}
}

View File

@@ -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',
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
) {}
}

View 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';

View File

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

View File

@@ -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[]>;
}

View File

@@ -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,
];

View File

@@ -0,0 +1 @@
export { NotificationsModule } from './notifications.module';

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

View 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 {}

View File

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