diff --git a/apps/api/src/modules/notifications/application/__tests__/inquiry-received.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/inquiry-received.listener.spec.ts new file mode 100644 index 0000000..f342a02 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/inquiry-received.listener.spec.ts @@ -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 }; + let mockPrisma: { + listing: { findUnique: ReturnType }; + user: { findUnique: ReturnType }; + notificationPreference: { findFirst: ReturnType }; + }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + const makeEvent = (overrides?: Partial): 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'); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/listing-approved.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/listing-approved.listener.spec.ts new file mode 100644 index 0000000..667f6a8 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/listing-approved.listener.spec.ts @@ -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 }; + let mockPrisma: { listing: { findUnique: ReturnType } }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/listing-rejected.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/listing-rejected.listener.spec.ts new file mode 100644 index 0000000..3e009f9 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/listing-rejected.listener.spec.ts @@ -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 }; + let mockPrisma: { listing: { findUnique: ReturnType } }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/payment-completed.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/payment-completed.listener.spec.ts new file mode 100644 index 0000000..efa8bf4 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/payment-completed.listener.spec.ts @@ -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 }; + let mockPrisma: { user: { findUnique: ReturnType } }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/subscription-expiring.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/subscription-expiring.listener.spec.ts new file mode 100644 index 0000000..6de386c --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/subscription-expiring.listener.spec.ts @@ -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 }; + let mockPrisma: { user: { findUnique: ReturnType } }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + 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(); + }); +}); diff --git a/apps/api/src/modules/notifications/application/listeners/inquiry-received.listener.ts b/apps/api/src/modules/notifications/application/listeners/inquiry-received.listener.ts new file mode 100644 index 0000000..6b3f2e8 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/inquiry-received.listener.ts @@ -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 { + 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 { + 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; + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/listing-approved.listener.ts b/apps/api/src/modules/notifications/application/listeners/listing-approved.listener.ts new file mode 100644 index 0000000..ae8f066 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/listing-approved.listener.ts @@ -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 { + 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, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/listing-rejected.listener.ts b/apps/api/src/modules/notifications/application/listeners/listing-rejected.listener.ts new file mode 100644 index 0000000..20251ce --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/listing-rejected.listener.ts @@ -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 { + 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, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/payment-completed.listener.ts b/apps/api/src/modules/notifications/application/listeners/payment-completed.listener.ts new file mode 100644 index 0000000..c51fe83 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/payment-completed.listener.ts @@ -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 { + 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, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/subscription-expiring.listener.ts b/apps/api/src/modules/notifications/application/listeners/subscription-expiring.listener.ts new file mode 100644 index 0000000..c11858f --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/subscription-expiring.listener.ts @@ -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 { + 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, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/domain/entities/notification.entity.ts b/apps/api/src/modules/notifications/domain/entities/notification.entity.ts index 3375562..208f5a4 100644 --- a/apps/api/src/modules/notifications/domain/entities/notification.entity.ts +++ b/apps/api/src/modules/notifications/domain/entities/notification.entity.ts @@ -13,5 +13,6 @@ export interface NotificationEntity { status: NotificationStatus; errorDetail: string | null; sentAt: Date | null; + readAt: Date | null; createdAt: Date; } diff --git a/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts b/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts index 714e56b..66b800e 100644 --- a/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts +++ b/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts @@ -16,4 +16,8 @@ export interface INotificationRepository { create(dto: CreateNotificationDto): Promise; updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise; findByUserId(userId: string, limit?: number): Promise; + findUnreadByUserId(userId: string, limit?: number): Promise; + countUnreadByUserId(userId: string): Promise; + markAsRead(id: string, userId: string): Promise; + markAllAsRead(userId: string): Promise; } diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts index f92d4bd..282bf90 100644 --- a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts @@ -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'); }); }); diff --git a/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts b/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts index a028ab0..3e8c7d1 100644 --- a/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts +++ b/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts @@ -46,6 +46,36 @@ export class PrismaNotificationRepository implements INotificationRepository { return records.map((r) => this.toEntity(r)); } + async findUnreadByUserId(userId: string, limit = 50): Promise { + 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 { + return this.prisma.notificationLog.count({ + where: { userId, readAt: null }, + }); + } + + async markAsRead(id: string, userId: string): Promise { + await this.prisma.notificationLog.updateMany({ + where: { id, userId, readAt: null }, + data: { readAt: new Date() }, + }); + } + + async markAllAsRead(userId: string): Promise { + 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, }; } diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts index b25b13d..563a0a4 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -52,6 +52,29 @@ const TEMPLATES: Record = {

Bạn đã yêu cầu đặt lại mật khẩu. Sử dụng mã OTP sau: {{otp}}

Mã có hiệu lực trong {{expiryMinutes}} phút.

Nếu bạn không yêu cầu, hãy bỏ qua email này.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'listing.rejected': { + subject: 'Tin đăng không được duyệt', + body: `

Tin đăng bị từ chối

+

Tin đăng {{listingTitle}} của bạn không được duyệt.

+

Lý do: {{reason}}

+

Vui lòng chỉnh sửa và gửi lại tin đăng.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'payment.confirmed': { + subject: 'Xác nhận thanh toán thành công', + body: `

Thanh toán thành công!

+

Giao dịch {{paymentId}} đã được xử lý thành công.

+

Số tiền: {{amountVND}} VNĐ

+

Phương thức: {{provider}}

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'subscription.expiring': { + subject: 'Gói đăng ký sắp hết hạn', + body: `

Gói đăng ký đã bị huỷ

+

Gói {{planTier}} của bạn đã bị huỷ.

+

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.

Trân trọng,
Đội ngũ GoodGo

`, }, }; diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index e3d7c3d..f76e746 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -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], diff --git a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts index edefcc2..8725644 100644 --- a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts +++ b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts @@ -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' }) diff --git a/prisma/migrations/20260409000000_add_notification_read_at/migration.sql b/prisma/migrations/20260409000000_add_notification_read_at/migration.sql new file mode 100644 index 0000000..c100ed9 --- /dev/null +++ b/prisma/migrations/20260409000000_add_notification_read_at/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "NotificationLog" ADD COLUMN "readAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "NotificationLog_userId_readAt_idx" ON "NotificationLog"("userId", "readAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82f17a9..78f21e8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 {