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;
|
status: NotificationStatus;
|
||||||
errorDetail: string | null;
|
errorDetail: string | null;
|
||||||
sentAt: Date | null;
|
sentAt: Date | null;
|
||||||
|
readAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,8 @@ export interface INotificationRepository {
|
|||||||
create(dto: CreateNotificationDto): Promise<NotificationEntity>;
|
create(dto: CreateNotificationDto): Promise<NotificationEntity>;
|
||||||
updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise<void>;
|
updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise<void>;
|
||||||
findByUserId(userId: string, limit?: number): Promise<NotificationEntity[]>;
|
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);
|
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();
|
const keys = service.getTemplateKeys();
|
||||||
|
|
||||||
expect(keys).toHaveLength(6);
|
expect(keys).toHaveLength(9);
|
||||||
expect(keys).toContain('user.registered');
|
expect(keys).toContain('user.registered');
|
||||||
expect(keys).toContain('agent.verified');
|
expect(keys).toContain('agent.verified');
|
||||||
expect(keys).toContain('listing.approved');
|
expect(keys).toContain('listing.approved');
|
||||||
|
expect(keys).toContain('listing.rejected');
|
||||||
expect(keys).toContain('inquiry.received');
|
expect(keys).toContain('inquiry.received');
|
||||||
expect(keys).toContain('quota.exceeded');
|
expect(keys).toContain('quota.exceeded');
|
||||||
expect(keys).toContain('password.reset');
|
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));
|
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: {
|
private toEntity(record: {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -57,6 +87,7 @@ export class PrismaNotificationRepository implements INotificationRepository {
|
|||||||
status: string;
|
status: string;
|
||||||
errorDetail: string | null;
|
errorDetail: string | null;
|
||||||
sentAt: Date | null;
|
sentAt: Date | null;
|
||||||
|
readAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): NotificationEntity {
|
}): NotificationEntity {
|
||||||
return {
|
return {
|
||||||
@@ -70,6 +101,7 @@ export class PrismaNotificationRepository implements INotificationRepository {
|
|||||||
status: record.status as NotificationStatus,
|
status: record.status as NotificationStatus,
|
||||||
errorDetail: record.errorDetail,
|
errorDetail: record.errorDetail,
|
||||||
sentAt: record.sentAt,
|
sentAt: record.sentAt,
|
||||||
|
readAt: record.readAt,
|
||||||
createdAt: record.createdAt,
|
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>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>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>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>`,
|
<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 { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
||||||
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
|
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 { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
|
||||||
|
import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
|
||||||
import { UserRegisteredListener } from './application/listeners/user-registered.listener';
|
import { UserRegisteredListener } from './application/listeners/user-registered.listener';
|
||||||
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
||||||
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
||||||
@@ -15,7 +20,16 @@ import { NotificationsController } from './presentation/controllers/notification
|
|||||||
|
|
||||||
const CommandHandlers = [SendNotificationHandler];
|
const CommandHandlers = [SendNotificationHandler];
|
||||||
|
|
||||||
const EventListeners = [UserRegisteredListener, AgentVerifiedListener, QuotaExceededListener];
|
const EventListeners = [
|
||||||
|
UserRegisteredListener,
|
||||||
|
AgentVerifiedListener,
|
||||||
|
QuotaExceededListener,
|
||||||
|
ListingApprovedListener,
|
||||||
|
ListingRejectedListener,
|
||||||
|
PaymentCompletedListener,
|
||||||
|
SubscriptionExpiringListener,
|
||||||
|
InquiryReceivedListener,
|
||||||
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule],
|
imports: [CqrsModule],
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Put,
|
Put,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Inject,
|
Inject,
|
||||||
@@ -79,6 +81,43 @@ export class NotificationsController {
|
|||||||
return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled);
|
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')
|
@Get('templates')
|
||||||
@ApiOperation({ summary: 'Get available notification templates' })
|
@ApiOperation({ summary: 'Get available notification templates' })
|
||||||
@ApiResponse({ status: 200, description: 'Templates retrieved' })
|
@ApiResponse({ status: 200, description: 'Templates retrieved' })
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationLog" ADD COLUMN "readAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_userId_readAt_idx" ON "NotificationLog"("userId", "readAt");
|
||||||
@@ -531,12 +531,14 @@ model NotificationLog {
|
|||||||
status NotificationStatus @default(PENDING)
|
status NotificationStatus @default(PENDING)
|
||||||
errorDetail String?
|
errorDetail String?
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
|
readAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([channel, status])
|
@@index([channel, status])
|
||||||
@@index([templateKey])
|
@@index([templateKey])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([userId, readAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NotificationPreference {
|
model NotificationPreference {
|
||||||
|
|||||||
Reference in New Issue
Block a user