feat(notifications): complete notification delivery system with email, push, and in-app support

Add 5 new event listeners (listing.approved, listing.rejected, payment.confirmed,
subscription.expiring, inquiry.received), 3 new Handlebars templates, readAt field
for in-app read/unread tracking, unread/mark-as-read API endpoints, and unit tests.

All 57 notification tests pass, lint clean, typecheck clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 00:11:34 +07:00
parent 47d9c94539
commit 6f3e6998ac
19 changed files with 695 additions and 3 deletions

View File

@@ -0,0 +1,84 @@
import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
import { type InquiryReceivedEvent, InquiryReceivedListener } from '../listeners/inquiry-received.listener';
describe('InquiryReceivedListener', () => {
let listener: InquiryReceivedListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
notificationPreference: { findFirst: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
const makeEvent = (overrides?: Partial<InquiryReceivedEvent>): InquiryReceivedEvent => ({
eventName: 'inquiry.received',
aggregateId: 'inq-1',
listingId: 'listing-1',
senderId: 'sender-1',
message: 'Tôi muốn xem nhà',
...overrides,
});
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
listing: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
notificationPreference: { findFirst: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
listener = new InquiryReceivedListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('sends email to agent when inquiry is received', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
property: { title: 'Căn hộ 3PN Thủ Đức' },
seller: { id: 'seller-1', email: 'seller@test.com' },
agent: { user: { id: 'agent-user-1', email: 'agent@test.com' } },
});
mockPrisma.user.findUnique.mockResolvedValue({ fullName: 'Nguyễn Văn A', phone: '0901234567' });
mockPrisma.notificationPreference.findFirst.mockResolvedValue(null);
await listener.handle(makeEvent());
// Should send email to agent
const emailCmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(emailCmd.userId).toBe('agent-user-1');
expect(emailCmd.channel).toBe('EMAIL');
expect(emailCmd.templateKey).toBe('inquiry.received');
expect(emailCmd.templateData.senderName).toBe('Nguyễn Văn A');
// Should also send email to seller (different from agent)
const sellerCmd = mockCommandBus.execute.mock.calls[1]![0] as SendNotificationCommand;
expect(sellerCmd.userId).toBe('seller-1');
expect(sellerCmd.channel).toBe('EMAIL');
});
it('skips notification when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
await listener.handle(makeEvent());
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('uses fallback sender name when user has no fullName', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
property: { title: 'Nhà phố' },
seller: { id: 'seller-1', email: 'seller@test.com' },
agent: null,
});
mockPrisma.user.findUnique.mockResolvedValue({ fullName: null, phone: '0901234567' });
await listener.handle(makeEvent());
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.templateData.senderName).toBe('0901234567');
});
});

View File

@@ -0,0 +1,66 @@
import { ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
import { ListingApprovedListener } from '../listeners/listing-approved.listener';
describe('ListingApprovedListener', () => {
let listener: ListingApprovedListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: { listing: { findUnique: ReturnType<typeof vi.fn> } };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
listing: { findUnique: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
listener = new ListingApprovedListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('sends email notification when listing is approved', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
property: { title: 'Căn hộ 2PN Quận 7' },
seller: { id: 'seller-1', email: 'seller@test.com' },
});
const event = new ListingApprovedEvent('listing-1', 'admin-1');
await listener.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.any(SendNotificationCommand),
);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.userId).toBe('seller-1');
expect(cmd.channel).toBe('EMAIL');
expect(cmd.templateKey).toBe('listing.approved');
expect(cmd.templateData).toEqual({ listingTitle: 'Căn hộ 2PN Quận 7' });
expect(cmd.recipientAddress).toBe('seller@test.com');
});
it('skips notification when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const event = new ListingApprovedEvent('listing-1', 'admin-1');
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('skips notification when seller has no email', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
property: { title: 'Căn hộ 2PN Quận 7' },
seller: { id: 'seller-1', email: null },
});
const event = new ListingApprovedEvent('listing-1', 'admin-1');
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import { ListingRejectedEvent } from '@modules/admin/domain/events/listing-rejected.event';
import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
import { ListingRejectedListener } from '../listeners/listing-rejected.listener';
describe('ListingRejectedListener', () => {
let listener: ListingRejectedListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: { listing: { findUnique: ReturnType<typeof vi.fn> } };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
listing: { findUnique: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
listener = new ListingRejectedListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('sends email notification when listing is rejected', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
property: { title: 'Nhà phố Quận 1' },
seller: { id: 'seller-1', email: 'seller@test.com' },
});
const event = new ListingRejectedEvent('listing-1', 'admin-1', 'Hình ảnh không rõ ràng');
await listener.handle(event);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.userId).toBe('seller-1');
expect(cmd.channel).toBe('EMAIL');
expect(cmd.templateKey).toBe('listing.rejected');
expect(cmd.templateData).toEqual({
listingTitle: 'Nhà phố Quận 1',
reason: 'Hình ảnh không rõ ràng',
});
});
it('skips notification when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const event = new ListingRejectedEvent('listing-1', 'admin-1', 'reason');
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { PaymentCompletedEvent } from '@modules/payments/domain/events/payment-completed.event';
import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
import { PaymentCompletedListener } from '../listeners/payment-completed.listener';
describe('PaymentCompletedListener', () => {
let listener: PaymentCompletedListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: { user: { findUnique: ReturnType<typeof vi.fn> } };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
user: { findUnique: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
listener = new PaymentCompletedListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('sends payment confirmation email', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ email: 'buyer@test.com' });
const event = new PaymentCompletedEvent('pay-1', 'user-1', 'VNPAY', BigInt(5000000));
await listener.handle(event);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.userId).toBe('user-1');
expect(cmd.channel).toBe('EMAIL');
expect(cmd.templateKey).toBe('payment.confirmed');
expect(cmd.templateData.paymentId).toBe('pay-1');
expect(cmd.templateData.provider).toBe('VNPAY');
expect(cmd.recipientAddress).toBe('buyer@test.com');
});
it('skips notification when user not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
const event = new PaymentCompletedEvent('pay-1', 'user-1', 'VNPAY', BigInt(5000000));
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('skips notification when user has no email', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ email: null });
const event = new PaymentCompletedEvent('pay-1', 'user-1', 'VNPAY', BigInt(5000000));
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
import { SubscriptionCancelledEvent } from '@modules/subscriptions/domain/events/subscription-cancelled.event';
import type { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
import { SubscriptionExpiringListener } from '../listeners/subscription-expiring.listener';
describe('SubscriptionExpiringListener', () => {
let listener: SubscriptionExpiringListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: { user: { findUnique: ReturnType<typeof vi.fn> } };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
user: { findUnique: vi.fn() },
};
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
listener = new SubscriptionExpiringListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('sends subscription expiring email', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ email: 'user@test.com' });
const event = new SubscriptionCancelledEvent('sub-1', 'user-1', 'PRO');
await listener.handle(event);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand;
expect(cmd.userId).toBe('user-1');
expect(cmd.channel).toBe('EMAIL');
expect(cmd.templateKey).toBe('subscription.expiring');
expect(cmd.templateData).toEqual({ planTier: 'PRO' });
expect(cmd.recipientAddress).toBe('user@test.com');
});
it('skips notification when user has no email', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ email: null });
const event = new SubscriptionCancelledEvent('sub-1', 'user-1', 'PRO');
await listener.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
export interface InquiryReceivedEvent {
readonly eventName: string;
readonly aggregateId: string;
readonly listingId: string;
readonly senderId: string;
readonly message: string;
}
@Injectable()
export class InquiryReceivedListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('inquiry.received', { async: true })
async handle(event: InquiryReceivedEvent): Promise<void> {
this.logger.log(`Handling inquiry.received for listing ${event.listingId}`, 'InquiryReceivedListener');
const listing = await this.prisma.listing.findUnique({
where: { id: event.listingId },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true } },
agent: { include: { user: { select: { id: true, email: true } } } },
},
});
if (!listing) return;
const sender = await this.prisma.user.findUnique({
where: { id: event.senderId },
select: { fullName: true, phone: true },
});
const senderName = sender?.fullName ?? sender?.phone ?? 'Khách hàng';
const listingTitle = listing.property.title;
const templateData = { senderName, listingTitle, message: event.message };
// Notify the agent via email + push
if (listing.agent?.user.email) {
await this.commandBus.execute(
new SendNotificationCommand(
listing.agent.user.id,
'EMAIL',
'inquiry.received',
templateData,
listing.agent.user.email,
),
);
}
// Notify agent via push if they have a token
if (listing.agent?.user.id) {
const token = await this.getFcmToken(listing.agent.user.id);
if (token) {
await this.commandBus.execute(
new SendNotificationCommand(
listing.agent.user.id,
'PUSH',
'inquiry.received',
templateData,
token,
),
);
}
}
// Also notify seller if different from agent
if (listing.seller.email && listing.seller.id !== listing.agent?.user.id) {
await this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'EMAIL',
'inquiry.received',
templateData,
listing.seller.email,
),
);
}
}
private async getFcmToken(userId: string): Promise<string | null> {
const pref = await this.prisma.notificationPreference.findFirst({
where: { userId, channel: 'PUSH', enabled: true },
});
// FCM token stored in metadata — for now return null until token storage is implemented
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- metadata shape is dynamic
return (pref as any)?.metadata?.fcmToken ?? null;
}
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class ListingApprovedListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.approved_by_admin', { async: true })
async handle(event: ListingApprovedEvent): Promise<void> {
this.logger.log(`Handling listing.approved_by_admin for ${event.aggregateId}`, 'ListingApprovedListener');
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true } },
},
});
if (!listing?.seller.email) return;
await this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'EMAIL',
'listing.approved',
{ listingTitle: listing.property.title },
listing.seller.email,
),
);
}
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingRejectedEvent } from '@modules/admin/domain/events/listing-rejected.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class ListingRejectedListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.rejected_by_admin', { async: true })
async handle(event: ListingRejectedEvent): Promise<void> {
this.logger.log(`Handling listing.rejected_by_admin for ${event.aggregateId}`, 'ListingRejectedListener');
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true } },
},
});
if (!listing?.seller.email) return;
await this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'EMAIL',
'listing.rejected',
{ listingTitle: listing.property.title, reason: event.reason },
listing.seller.email,
),
);
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type PaymentCompletedEvent } from '@modules/payments/domain/events/payment-completed.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class PaymentCompletedListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('payment.completed', { async: true })
async handle(event: PaymentCompletedEvent): Promise<void> {
this.logger.log(`Handling payment.completed for ${event.aggregateId}`, 'PaymentCompletedListener');
const user = await this.prisma.user.findUnique({
where: { id: event.userId },
select: { email: true },
});
if (!user?.email) return;
const amountFormatted = new Intl.NumberFormat('vi-VN').format(event.amountVND);
await this.commandBus.execute(
new SendNotificationCommand(
event.userId,
'EMAIL',
'payment.confirmed',
{
paymentId: event.aggregateId,
amountVND: amountFormatted,
provider: event.provider,
},
user.email,
),
);
}
}

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type SubscriptionCancelledEvent } from '@modules/subscriptions/domain/events/subscription-cancelled.event';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class SubscriptionExpiringListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('subscription.cancelled', { async: true })
async handle(event: SubscriptionCancelledEvent): Promise<void> {
this.logger.log(`Handling subscription.cancelled for ${event.aggregateId}`, 'SubscriptionExpiringListener');
const user = await this.prisma.user.findUnique({
where: { id: event.userId },
select: { email: true },
});
if (!user?.email) return;
await this.commandBus.execute(
new SendNotificationCommand(
event.userId,
'EMAIL',
'subscription.expiring',
{ planTier: event.planTier },
user.email,
),
);
}
}

View File

@@ -13,5 +13,6 @@ export interface NotificationEntity {
status: NotificationStatus;
errorDetail: string | null;
sentAt: Date | null;
readAt: Date | null;
createdAt: Date;
}

View File

@@ -16,4 +16,8 @@ export interface INotificationRepository {
create(dto: CreateNotificationDto): Promise<NotificationEntity>;
updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise<void>;
findByUserId(userId: string, limit?: number): Promise<NotificationEntity[]>;
findUnreadByUserId(userId: string, limit?: number): Promise<NotificationEntity[]>;
countUnreadByUserId(userId: string): Promise<number>;
markAsRead(id: string, userId: string): Promise<void>;
markAllAsRead(userId: string): Promise<number>;
}

View File

@@ -39,15 +39,18 @@ describe('TemplateService', () => {
expect(service.hasTemplate('unknown.template')).toBe(false);
});
it('getTemplateKeys returns all 6 template keys', () => {
it('getTemplateKeys returns all 9 template keys', () => {
const keys = service.getTemplateKeys();
expect(keys).toHaveLength(6);
expect(keys).toHaveLength(9);
expect(keys).toContain('user.registered');
expect(keys).toContain('agent.verified');
expect(keys).toContain('listing.approved');
expect(keys).toContain('listing.rejected');
expect(keys).toContain('inquiry.received');
expect(keys).toContain('quota.exceeded');
expect(keys).toContain('password.reset');
expect(keys).toContain('payment.confirmed');
expect(keys).toContain('subscription.expiring');
});
});

View File

@@ -46,6 +46,36 @@ export class PrismaNotificationRepository implements INotificationRepository {
return records.map((r) => this.toEntity(r));
}
async findUnreadByUserId(userId: string, limit = 50): Promise<NotificationEntity[]> {
const records = await this.prisma.notificationLog.findMany({
where: { userId, readAt: null },
orderBy: { createdAt: 'desc' },
take: limit,
});
return records.map((r) => this.toEntity(r));
}
async countUnreadByUserId(userId: string): Promise<number> {
return this.prisma.notificationLog.count({
where: { userId, readAt: null },
});
}
async markAsRead(id: string, userId: string): Promise<void> {
await this.prisma.notificationLog.updateMany({
where: { id, userId, readAt: null },
data: { readAt: new Date() },
});
}
async markAllAsRead(userId: string): Promise<number> {
const result = await this.prisma.notificationLog.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
return result.count;
}
private toEntity(record: {
id: string;
userId: string;
@@ -57,6 +87,7 @@ export class PrismaNotificationRepository implements INotificationRepository {
status: string;
errorDetail: string | null;
sentAt: Date | null;
readAt: Date | null;
createdAt: Date;
}): NotificationEntity {
return {
@@ -70,6 +101,7 @@ export class PrismaNotificationRepository implements INotificationRepository {
status: record.status as NotificationStatus,
errorDetail: record.errorDetail,
sentAt: record.sentAt,
readAt: record.readAt,
createdAt: record.createdAt,
};
}

View File

@@ -52,6 +52,29 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
<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>`,
},
'listing.rejected': {
subject: 'Tin đăng không được duyệt',
body: `<h1>Tin đăng bị từ chối</h1>
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn không được duyệt.</p>
<p>Lý do: {{reason}}</p>
<p>Vui lòng chỉnh sửa và gửi lại tin đăng.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'payment.confirmed': {
subject: 'Xác nhận thanh toán thành công',
body: `<h1>Thanh toán thành công!</h1>
<p>Giao dịch <strong>{{paymentId}}</strong> đã được xử lý thành công.</p>
<p>Số tiền: <strong>{{amountVND}} VNĐ</strong></p>
<p>Phương thức: {{provider}}</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'subscription.expiring': {
subject: 'Gói đăng ký sắp hết hạn',
body: `<h1>Gói đăng ký đã bị huỷ</h1>
<p>Gói <strong>{{planTier}}</strong> của bạn đã bị huỷ.</p>
<p>Bạn có thể đăng ký lại bất cứ lúc nào để tiếp tục sử dụng đầy đủ tính năng.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
};

View File

@@ -2,7 +2,12 @@ import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
import { ListingApprovedListener } from './application/listeners/listing-approved.listener';
import { ListingRejectedListener } from './application/listeners/listing-rejected.listener';
import { PaymentCompletedListener } from './application/listeners/payment-completed.listener';
import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
import { UserRegisteredListener } from './application/listeners/user-registered.listener';
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
@@ -15,7 +20,16 @@ import { NotificationsController } from './presentation/controllers/notification
const CommandHandlers = [SendNotificationHandler];
const EventListeners = [UserRegisteredListener, AgentVerifiedListener, QuotaExceededListener];
const EventListeners = [
UserRegisteredListener,
AgentVerifiedListener,
QuotaExceededListener,
ListingApprovedListener,
ListingRejectedListener,
PaymentCompletedListener,
SubscriptionExpiringListener,
InquiryReceivedListener,
];
@Module({
imports: [CqrsModule],

View File

@@ -2,7 +2,9 @@ import {
Controller,
Get,
Put,
Patch,
Body,
Param,
Query,
UseGuards,
Inject,
@@ -79,6 +81,43 @@ export class NotificationsController {
return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled);
}
@Get('unread')
@ApiOperation({ summary: 'Get unread notifications' })
@ApiResponse({ status: 200, description: 'Unread notifications retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getUnread(
@CurrentUser() user: JwtPayload,
@Query('limit') limit?: number,
) {
const [notifications, count] = await Promise.all([
this.notificationRepo.findUnreadByUserId(user.sub, limit ?? 50),
this.notificationRepo.countUnreadByUserId(user.sub),
]);
return { notifications, unreadCount: count };
}
@Patch(':id/read')
@ApiOperation({ summary: 'Mark a notification as read' })
@ApiResponse({ status: 200, description: 'Notification marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async markAsRead(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
await this.notificationRepo.markAsRead(id, user.sub);
return { success: true };
}
@Patch('read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
@ApiResponse({ status: 200, description: 'All notifications marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async markAllAsRead(@CurrentUser() user: JwtPayload) {
const count = await this.notificationRepo.markAllAsRead(user.sub);
return { markedCount: count };
}
@Get('templates')
@ApiOperation({ summary: 'Get available notification templates' })
@ApiResponse({ status: 200, description: 'Templates retrieved' })

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "NotificationLog" ADD COLUMN "readAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "NotificationLog_userId_readAt_idx" ON "NotificationLog"("userId", "readAt");

View File

@@ -531,12 +531,14 @@ model NotificationLog {
status NotificationStatus @default(PENDING)
errorDetail String?
sentAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([channel, status])
@@index([templateKey])
@@index([createdAt])
@@index([userId, readAt])
}
model NotificationPreference {