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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@ export interface NotificationEntity {
|
||||
status: NotificationStatus;
|
||||
errorDetail: string | null;
|
||||
sentAt: Date | null;
|
||||
readAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user