diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 38b0fae..2d9d5c1 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -12,6 +12,7 @@ import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.h import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler'; import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler'; import { UserBannedListener } from './application/listeners/user-banned.listener'; +import { UserDeactivatedListener } from './application/listeners/user-deactivated.listener'; import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler'; import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler'; import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler'; @@ -55,6 +56,7 @@ const QueryHandlers = [ // Event Listeners UserBannedListener, + UserDeactivatedListener, ], }) export class AdminModule {} diff --git a/apps/api/src/modules/admin/application/listeners/user-deactivated.listener.ts b/apps/api/src/modules/admin/application/listeners/user-deactivated.listener.ts new file mode 100644 index 0000000..8092526 --- /dev/null +++ b/apps/api/src/modules/admin/application/listeners/user-deactivated.listener.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type UserDeactivatedEvent } from '@modules/auth'; +import { type LoggerService, type PrismaService } from '@modules/shared'; + +@Injectable() +export class UserDeactivatedListener { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('user.deactivated', { async: true }) + async handle(event: UserDeactivatedEvent): Promise { + this.logger.log(`Handling user.deactivated for user ${event.aggregateId}`, 'UserDeactivatedListener'); + + const deactivated = await this.prisma.listing.updateMany({ + where: { + sellerId: event.aggregateId, + status: { in: ['ACTIVE', 'PENDING_REVIEW'] }, + }, + data: { status: 'EXPIRED' }, + }); + + this.logger.log( + `Expired ${deactivated.count} listings for deactivated user ${event.aggregateId}`, + 'UserDeactivatedListener', + ); + } +} diff --git a/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts b/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts index 207d422..9a0361e 100644 --- a/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts +++ b/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; import { AgentVerifiedEvent } from '../events/agent-verified.event'; +import { UserDeactivatedEvent } from '../events/user-deactivated.event'; +import { UserKycUpdatedEvent } from '../events/user-kyc-updated.event'; import { UserRegisteredEvent } from '../events/user-registered.event'; describe('Auth Domain Events', () => { @@ -35,4 +37,32 @@ describe('Auth Domain Events', () => { expect(event.occurredAt).toBeInstanceOf(Date); }); }); + + describe('UserKycUpdatedEvent', () => { + it('creates event with correct properties', () => { + const event = new UserKycUpdatedEvent('user-1', 'APPROVED', 'PENDING'); + + expect(event.eventName).toBe('user.kyc_updated'); + expect(event.aggregateId).toBe('user-1'); + expect(event.newStatus).toBe('APPROVED'); + expect(event.previousStatus).toBe('PENDING'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + + it('creates event for rejection', () => { + const event = new UserKycUpdatedEvent('user-2', 'REJECTED', 'PENDING'); + expect(event.newStatus).toBe('REJECTED'); + expect(event.previousStatus).toBe('PENDING'); + }); + }); + + describe('UserDeactivatedEvent', () => { + it('creates event with correct properties', () => { + const event = new UserDeactivatedEvent('user-1'); + + expect(event.eventName).toBe('user.deactivated'); + expect(event.aggregateId).toBe('user-1'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); }); diff --git a/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts b/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts index 6dff868..2d8d433 100644 --- a/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts +++ b/apps/api/src/modules/auth/domain/__tests__/user.entity.spec.ts @@ -38,20 +38,30 @@ describe('UserEntity', () => { expect(user.email?.value).toBe('test@example.com'); }); - it('should update KYC status', () => { + it('should update KYC status and emit UserKycUpdatedEvent', () => { const user = UserEntity.createNew('user-3', phone, 'Lê Văn C', passwordHash); + user.clearDomainEvents(); user.updateKycStatus('PENDING', { idCard: '123456789' }); expect(user.kycStatus).toBe('PENDING'); expect(user.kycData).toEqual({ idCard: '123456789' }); + + const events = user.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('user.kyc_updated'); }); - it('should deactivate user', () => { + it('should deactivate user and emit UserDeactivatedEvent', () => { const user = UserEntity.createNew('user-4', phone, 'Phạm Thị D', passwordHash); + user.clearDomainEvents(); expect(user.isActive).toBe(true); user.deactivate(); expect(user.isActive).toBe(false); + + const events = user.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('user.deactivated'); }); it('should clear domain events', () => { diff --git a/apps/api/src/modules/auth/domain/entities/user.entity.ts b/apps/api/src/modules/auth/domain/entities/user.entity.ts index ed58f73..880f8ef 100644 --- a/apps/api/src/modules/auth/domain/entities/user.entity.ts +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -1,5 +1,7 @@ import { type UserRole, type KYCStatus } from '@prisma/client'; import { AggregateRoot } from '@modules/shared'; +import { UserDeactivatedEvent } from '../events/user-deactivated.event'; +import { UserKycUpdatedEvent } from '../events/user-kyc-updated.event'; import { UserRegisteredEvent } from '../events/user-registered.event'; import { type Email } from '../value-objects/email.vo'; import { type HashedPassword } from '../value-objects/hashed-password.vo'; @@ -76,14 +78,19 @@ export class UserEntity extends AggregateRoot { } updateKycStatus(status: KYCStatus, kycData?: unknown): void { + const previousStatus = this._kycStatus; this._kycStatus = status; if (kycData !== undefined) this._kycData = kycData; this.updatedAt = new Date(); + + this.addDomainEvent(new UserKycUpdatedEvent(this.id, status, previousStatus)); } deactivate(): void { this._isActive = false; this.updatedAt = new Date(); + + this.addDomainEvent(new UserDeactivatedEvent(this.id)); } activate(): void { diff --git a/apps/api/src/modules/auth/domain/events/user-deactivated.event.ts b/apps/api/src/modules/auth/domain/events/user-deactivated.event.ts new file mode 100644 index 0000000..ec2660a --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/user-deactivated.event.ts @@ -0,0 +1,10 @@ +import { type DomainEvent } from '@modules/shared'; + +export class UserDeactivatedEvent implements DomainEvent { + readonly eventName = 'user.deactivated'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + ) {} +} diff --git a/apps/api/src/modules/auth/domain/events/user-kyc-updated.event.ts b/apps/api/src/modules/auth/domain/events/user-kyc-updated.event.ts new file mode 100644 index 0000000..a45f37c --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/user-kyc-updated.event.ts @@ -0,0 +1,13 @@ +import { type KYCStatus } from '@prisma/client'; +import { type DomainEvent } from '@modules/shared'; + +export class UserKycUpdatedEvent implements DomainEvent { + readonly eventName = 'user.kyc_updated'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly newStatus: KYCStatus, + public readonly previousStatus: KYCStatus, + ) {} +} diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts index 5c0d01d..1c54634 100644 --- a/apps/api/src/modules/auth/index.ts +++ b/apps/api/src/modules/auth/index.ts @@ -8,5 +8,7 @@ export { UserEntity, type UserProps } from './domain/entities/user.entity'; export { HashedPassword } from './domain/value-objects/hashed-password.vo'; export { Phone } from './domain/value-objects/phone.vo'; export { AgentVerifiedEvent } from './domain/events/agent-verified.event'; +export { UserDeactivatedEvent } from './domain/events/user-deactivated.event'; +export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event'; export { UserRegisteredEvent } from './domain/events/user-registered.event'; export { USER_REPOSITORY, type IUserRepository } from './domain/repositories/user.repository'; diff --git a/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts b/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts index ca08ddc..0c952ce 100644 --- a/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts +++ b/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { ListingApprovedEvent } from '../events/listing-approved.event'; import { ListingCreatedEvent } from '../events/listing-created.event'; import { ListingSoldEvent } from '../events/listing-sold.event'; +import { ListingStatusChangedEvent } from '../events/listing-status-changed.event'; describe('Listings Domain Events', () => { describe('ListingCreatedEvent', () => { @@ -49,4 +50,29 @@ describe('Listings Domain Events', () => { expect(event.finalStatus).toBe('RENTED'); }); }); + + describe('ListingStatusChangedEvent', () => { + it('creates event with correct properties', () => { + const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW'); + + expect(event.eventName).toBe('listing.status_changed'); + expect(event.aggregateId).toBe('listing-1'); + expect(event.propertyId).toBe('prop-1'); + expect(event.previousStatus).toBe('DRAFT'); + expect(event.newStatus).toBe('PENDING_REVIEW'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + + it('creates event for rejection transition', () => { + const event = new ListingStatusChangedEvent('listing-2', 'prop-2', 'PENDING_REVIEW', 'REJECTED'); + expect(event.previousStatus).toBe('PENDING_REVIEW'); + expect(event.newStatus).toBe('REJECTED'); + }); + + it('creates event for expiration transition', () => { + const event = new ListingStatusChangedEvent('listing-3', 'prop-3', 'ACTIVE', 'EXPIRED'); + expect(event.previousStatus).toBe('ACTIVE'); + expect(event.newStatus).toBe('EXPIRED'); + }); + }); }); diff --git a/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts index 01838a4..3b20f26 100644 --- a/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts +++ b/apps/api/src/modules/listings/domain/__tests__/listing.entity.spec.ts @@ -33,10 +33,15 @@ describe('ListingEntity', () => { expect(events[0]!.eventName).toBe('listing.created'); }); - it('should transition DRAFT -> PENDING_REVIEW', () => { + it('should transition DRAFT -> PENDING_REVIEW and emit ListingStatusChangedEvent', () => { const listing = makeDefaultListing(); + listing.clearDomainEvents(); listing.submitForReview(); expect(listing.status).toBe('PENDING_REVIEW'); + + const events = listing.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]!.eventName).toBe('listing.status_changed'); }); it('should transition PENDING_REVIEW -> ACTIVE and emit ListingApprovedEvent', () => { @@ -51,12 +56,16 @@ describe('ListingEntity', () => { expect(events.some((e) => e.eventName === 'listing.approved')).toBe(true); }); - it('should reject a PENDING_REVIEW listing', () => { + it('should reject a PENDING_REVIEW listing and emit ListingStatusChangedEvent', () => { const listing = makeDefaultListing(); listing.submitForReview(); + listing.clearDomainEvents(); listing.reject('Ảnh không rõ ràng'); expect(listing.status).toBe('REJECTED'); expect(listing.moderationNotes).toBe('Ảnh không rõ ràng'); + + const events = listing.domainEvents; + expect(events.some((e) => e.eventName === 'listing.status_changed')).toBe(true); }); it('should transition ACTIVE -> SOLD and emit ListingSoldEvent', () => { diff --git a/apps/api/src/modules/listings/domain/entities/listing.entity.ts b/apps/api/src/modules/listings/domain/entities/listing.entity.ts index f1a6ece..cf40d42 100644 --- a/apps/api/src/modules/listings/domain/entities/listing.entity.ts +++ b/apps/api/src/modules/listings/domain/entities/listing.entity.ts @@ -3,6 +3,7 @@ import { AggregateRoot, ValidationException } from '@modules/shared'; import { ListingApprovedEvent } from '../events/listing-approved.event'; import { ListingCreatedEvent } from '../events/listing-created.event'; import { ListingSoldEvent } from '../events/listing-sold.event'; +import { ListingStatusChangedEvent } from '../events/listing-status-changed.event'; import { type Price } from '../value-objects/price.vo'; const VALID_TRANSITIONS: Record = { @@ -152,6 +153,11 @@ export class ListingEntity extends AggregateRoot { this._status = newStatus; this.updatedAt = new Date(); + // Always emit generic status change event for all transitions + this.addDomainEvent( + new ListingStatusChangedEvent(this.id, this._propertyId, previousStatus, newStatus), + ); + if (newStatus === 'ACTIVE' && previousStatus === 'PENDING_REVIEW') { this._publishedAt = new Date(); this.addDomainEvent(new ListingApprovedEvent(this.id, this._propertyId)); diff --git a/apps/api/src/modules/listings/domain/events/listing-status-changed.event.ts b/apps/api/src/modules/listings/domain/events/listing-status-changed.event.ts new file mode 100644 index 0000000..2553971 --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-status-changed.event.ts @@ -0,0 +1,14 @@ +import { type ListingStatus } from '@prisma/client'; +import { type DomainEvent } from '@modules/shared'; + +export class ListingStatusChangedEvent implements DomainEvent { + readonly eventName = 'listing.status_changed'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly propertyId: string, + public readonly previousStatus: ListingStatus, + public readonly newStatus: ListingStatus, + ) {} +} diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts index dee3168..02b3e6f 100644 --- a/apps/api/src/modules/listings/index.ts +++ b/apps/api/src/modules/listings/index.ts @@ -1,6 +1,8 @@ export { ListingsModule } from './listings.module'; export { ListingEntity, type ListingProps } from './domain/entities/listing.entity'; export { ListingCreatedEvent } from './domain/events/listing-created.event'; +export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command'; export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository'; export { ListingSoldEvent } from './domain/events/listing-sold.event'; +export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event'; export { Price } from './domain/value-objects/price.vo'; diff --git a/apps/api/src/modules/notifications/application/listeners/listing-sold.listener.ts b/apps/api/src/modules/notifications/application/listeners/listing-sold.listener.ts index 2b808b0..eb6685b 100644 --- a/apps/api/src/modules/notifications/application/listeners/listing-sold.listener.ts +++ b/apps/api/src/modules/notifications/application/listeners/listing-sold.listener.ts @@ -40,28 +40,8 @@ export class ListingSoldListener { ); } - // Notify users who saved/watched this listing - const watchers = await this.prisma.savedListing.findMany({ - where: { listingId: event.aggregateId }, - include: { user: { select: { id: true, email: true } } }, - }); - - for (const watcher of watchers) { - if (!watcher.user.email) continue; - - await this.commandBus.execute( - new SendNotificationCommand( - watcher.user.id, - 'EMAIL', - 'listing.sold_watcher', - { listingTitle: listing.property.title }, - watcher.user.email, - ), - ); - } - this.logger.log( - `Notified seller and ${watchers.length} watchers for listing ${event.aggregateId}`, + `Notified seller for listing ${event.aggregateId}`, 'ListingSoldListener', ); } diff --git a/apps/api/src/modules/notifications/application/listeners/payment-failed.listener.ts b/apps/api/src/modules/notifications/application/listeners/payment-failed.listener.ts new file mode 100644 index 0000000..b438a44 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/payment-failed.listener.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type PaymentFailedEvent } from '@modules/payments'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class PaymentFailedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('payment.failed', { async: true }) + async handle(event: PaymentFailedEvent): Promise { + this.logger.log(`Handling payment.failed for ${event.aggregateId}`, 'PaymentFailedListener'); + + 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', + 'payment.failed', + { + paymentId: event.aggregateId, + provider: event.provider, + }, + user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/payment-refunded.listener.ts b/apps/api/src/modules/notifications/application/listeners/payment-refunded.listener.ts new file mode 100644 index 0000000..b21351b --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/payment-refunded.listener.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type PaymentRefundedEvent } from '@modules/payments'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class PaymentRefundedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('payment.refunded', { async: true }) + async handle(event: PaymentRefundedEvent): Promise { + this.logger.log(`Handling payment.refunded for ${event.aggregateId}`, 'PaymentRefundedListener'); + + 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.refunded', + { + paymentId: event.aggregateId, + amountVND: amountFormatted, + provider: event.provider, + }, + user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/subscription-expired.listener.ts b/apps/api/src/modules/notifications/application/listeners/subscription-expired.listener.ts new file mode 100644 index 0000000..4bf5dba --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/subscription-expired.listener.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { type SubscriptionExpiredEvent } from '@modules/subscriptions'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class SubscriptionExpiredListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('subscription.expired', { async: true }) + async handle(event: SubscriptionExpiredEvent): Promise { + this.logger.log(`Handling subscription.expired for ${event.aggregateId}`, 'SubscriptionExpiredListener'); + + 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.expired', + { planTier: event.planTier }, + user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/subscription-renewed.listener.ts b/apps/api/src/modules/notifications/application/listeners/subscription-renewed.listener.ts new file mode 100644 index 0000000..e181cd0 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/subscription-renewed.listener.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { type SubscriptionRenewedEvent } from '@modules/subscriptions'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class SubscriptionRenewedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('subscription.renewed', { async: true }) + async handle(event: SubscriptionRenewedEvent): Promise { + this.logger.log(`Handling subscription.renewed for ${event.aggregateId}`, 'SubscriptionRenewedListener'); + + 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.renewed', + { + planTier: event.planTier, + periodEnd: event.newPeriodEnd.toISOString(), + }, + user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/user-kyc-updated.listener.ts b/apps/api/src/modules/notifications/application/listeners/user-kyc-updated.listener.ts new file mode 100644 index 0000000..fa40d01 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/user-kyc-updated.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 UserKycUpdatedEvent } from '@modules/auth'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class UserKycUpdatedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('user.kyc_updated', { async: true }) + async handle(event: UserKycUpdatedEvent): Promise { + this.logger.log(`Handling user.kyc_updated for ${event.aggregateId}`, 'UserKycUpdatedListener'); + + if (event.newStatus !== 'VERIFIED' && event.newStatus !== 'REJECTED') return; + + const user = await this.prisma.user.findUnique({ + where: { id: event.aggregateId }, + select: { email: true }, + }); + + if (!user?.email) return; + + const templateKey = event.newStatus === 'VERIFIED' ? 'kyc.approved' : 'kyc.rejected'; + + await this.commandBus.execute( + new SendNotificationCommand( + event.aggregateId, + 'EMAIL', + templateKey, + { previousStatus: event.previousStatus, newStatus: event.newStatus }, + user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index 621d6c6..fd4b4e7 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -7,8 +7,13 @@ import { ListingApprovedListener } from './application/listeners/listing-approve import { ListingRejectedListener } from './application/listeners/listing-rejected.listener'; import { ListingSoldListener } from './application/listeners/listing-sold.listener'; import { PaymentCompletedListener } from './application/listeners/payment-completed.listener'; +import { PaymentFailedListener } from './application/listeners/payment-failed.listener'; +import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener'; import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener'; +import { SubscriptionExpiredListener } from './application/listeners/subscription-expired.listener'; import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener'; +import { SubscriptionRenewedListener } from './application/listeners/subscription-renewed.listener'; +import { UserKycUpdatedListener } from './application/listeners/user-kyc-updated.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'; @@ -28,9 +33,14 @@ const EventListeners = [ ListingApprovedListener, ListingRejectedListener, PaymentCompletedListener, + PaymentFailedListener, + PaymentRefundedListener, SubscriptionExpiringListener, + SubscriptionExpiredListener, + SubscriptionRenewedListener, InquiryReceivedListener, ListingSoldListener, + UserKycUpdatedListener, ]; @Module({ diff --git a/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts b/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts index 9dd512f..2bdd1a1 100644 --- a/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts +++ b/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { PaymentCompletedEvent } from '../events/payment-completed.event'; import { PaymentCreatedEvent } from '../events/payment-created.event'; import { PaymentFailedEvent } from '../events/payment-failed.event'; +import { PaymentRefundedEvent } from '../events/payment-refunded.event'; describe('Payment Domain Events', () => { describe('PaymentCreatedEvent', () => { @@ -59,4 +60,17 @@ describe('Payment Domain Events', () => { expect(event.occurredAt).toBeInstanceOf(Date); }); }); + + describe('PaymentRefundedEvent', () => { + it('creates event with correct properties', () => { + const event = new PaymentRefundedEvent('payment-1', 'user-1', 'VNPAY', 500_000n); + + expect(event.eventName).toBe('payment.refunded'); + expect(event.aggregateId).toBe('payment-1'); + expect(event.userId).toBe('user-1'); + expect(event.provider).toBe('VNPAY'); + expect(event.amountVND).toBe(500_000n); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); }); diff --git a/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts b/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts index 5b789a4..0512124 100644 --- a/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts +++ b/apps/api/src/modules/payments/domain/__tests__/payment.entity.spec.ts @@ -3,6 +3,7 @@ import { PaymentEntity } from '../entities/payment.entity'; import { PaymentCompletedEvent } from '../events/payment-completed.event'; import { PaymentCreatedEvent } from '../events/payment-created.event'; import { PaymentFailedEvent } from '../events/payment-failed.event'; +import { PaymentRefundedEvent } from '../events/payment-refunded.event'; import { Money } from '../value-objects/money.vo'; describe('PaymentEntity', () => { @@ -107,11 +108,16 @@ describe('PaymentEntity', () => { expect(result.unwrapErr().message).toContain('Cannot fail payment'); }); - it('should mark completed payment as refunded', () => { + it('should mark completed payment as refunded and emit event', () => { const payment = createPayment('COMPLETED'); + payment.clearDomainEvents(); const result = payment.markRefunded(); expect(result.isOk).toBe(true); expect(payment.status).toBe('REFUNDED'); + + const events = payment.domainEvents; + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(PaymentRefundedEvent); }); it('should not refund a non-completed payment', () => { diff --git a/apps/api/src/modules/payments/domain/entities/payment.entity.ts b/apps/api/src/modules/payments/domain/entities/payment.entity.ts index 6255527..701081d 100644 --- a/apps/api/src/modules/payments/domain/entities/payment.entity.ts +++ b/apps/api/src/modules/payments/domain/entities/payment.entity.ts @@ -8,6 +8,7 @@ import { AggregateRoot, DomainException, ErrorCode, Result } from '@modules/shar import { PaymentCompletedEvent } from '../events/payment-completed.event'; import { PaymentCreatedEvent } from '../events/payment-created.event'; import { PaymentFailedEvent } from '../events/payment-failed.event'; +import { PaymentRefundedEvent } from '../events/payment-refunded.event'; import { type Money } from '../value-objects/money.vo'; export interface PaymentProps { @@ -156,6 +157,10 @@ export class PaymentEntity extends AggregateRoot { } this._status = 'REFUNDED'; this.updatedAt = new Date(); + + this.addDomainEvent( + new PaymentRefundedEvent(this.id, this._userId, this._provider, this._amount.value), + ); return Result.ok(undefined); } } diff --git a/apps/api/src/modules/payments/domain/events/payment-refunded.event.ts b/apps/api/src/modules/payments/domain/events/payment-refunded.event.ts new file mode 100644 index 0000000..9c94b79 --- /dev/null +++ b/apps/api/src/modules/payments/domain/events/payment-refunded.event.ts @@ -0,0 +1,14 @@ +import { type PaymentProvider } from '@prisma/client'; +import { type DomainEvent } from '@modules/shared'; + +export class PaymentRefundedEvent implements DomainEvent { + readonly eventName = 'payment.refunded'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly provider: PaymentProvider, + public readonly amountVND: bigint, + ) {} +} diff --git a/apps/api/src/modules/payments/index.ts b/apps/api/src/modules/payments/index.ts index 65c9565..bc3c7cc 100644 --- a/apps/api/src/modules/payments/index.ts +++ b/apps/api/src/modules/payments/index.ts @@ -2,3 +2,5 @@ export { PaymentsModule } from './payments.module'; export { PAYMENT_REPOSITORY, type IPaymentRepository } from './domain/repositories/payment.repository'; export { PAYMENT_GATEWAY_FACTORY, type IPaymentGatewayFactory } from './infrastructure/services/payment-gateway.interface'; export { PaymentCompletedEvent } from './domain/events/payment-completed.event'; +export { PaymentFailedEvent } from './domain/events/payment-failed.event'; +export { PaymentRefundedEvent } from './domain/events/payment-refunded.event'; diff --git a/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts b/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts index 95319e8..9a3dbc7 100644 --- a/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts +++ b/apps/api/src/modules/reviews/application/listeners/review-deleted.listener.ts @@ -17,12 +17,11 @@ export class ReviewDeletedListener { 'ReviewDeletedListener', ); - // Recalculate average rating for the target (agent or listing) + // Recalculate average rating for the target (agent) const stats = await this.prisma.review.aggregate({ where: { targetType: event.targetType, targetId: event.targetId, - deletedAt: null, }, _avg: { rating: true }, _count: { rating: true }, @@ -31,17 +30,12 @@ export class ReviewDeletedListener { const avgRating = stats._avg.rating ?? 0; const reviewCount = stats._count.rating; - // Update the target's cached rating based on target type + // Update the target's cached rating if (event.targetType === 'AGENT') { await this.prisma.agent.update({ where: { id: event.targetId }, data: { qualityScore: avgRating }, }); - } else if (event.targetType === 'LISTING') { - await this.prisma.listing.update({ - where: { id: event.targetId }, - data: { averageRating: avgRating, reviewCount }, - }); } this.logger.log( diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts index 9fe08d4..ae7135e 100644 --- a/apps/api/src/modules/search/infrastructure/event-handlers/index.ts +++ b/apps/api/src/modules/search/infrastructure/event-handlers/index.ts @@ -1 +1,2 @@ export { ListingApprovedEventHandler } from './listing-approved.handler'; +export { ListingStatusChangedHandler } from './listing-status-changed.handler'; diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/listing-status-changed.handler.ts b/apps/api/src/modules/search/infrastructure/event-handlers/listing-status-changed.handler.ts new file mode 100644 index 0000000..c50a6a4 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/event-handlers/listing-status-changed.handler.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type ListingStatusChangedEvent } from '@modules/listings'; +import { CacheService, CachePrefix, type LoggerService } from '@modules/shared'; +import { type ListingIndexerService } from '../services/listing-indexer.service'; + +@Injectable() +export class ListingStatusChangedHandler { + constructor( + private readonly indexer: ListingIndexerService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('listing.status_changed', { async: true }) + async handle(event: ListingStatusChangedEvent): Promise { + this.logger.log( + `Handling listing.status_changed: ${event.previousStatus} → ${event.newStatus} for ${event.aggregateId}`, + 'ListingStatusChangedHandler', + ); + + // Remove from search index when listing becomes inactive + const removeStatuses = ['REJECTED', 'EXPIRED', 'SOLD', 'RENTED']; + if (removeStatuses.includes(event.newStatus)) { + await this.indexer.removeListing(event.aggregateId); + } + + // Invalidate caches for any status change + await Promise.all([ + this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, event.aggregateId)), + this.cache.invalidateByPrefix(CachePrefix.SEARCH), + this.cache.invalidateByPrefix(CachePrefix.GEO_SEARCH), + ]); + } +} diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index 35f3da5..6f4a313 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -7,6 +7,7 @@ import { GeoSearchHandler } from './application/queries/geo-search/geo-search.ha import { SearchPropertiesHandler } from './application/queries/search-properties/search-properties.handler'; import { SEARCH_REPOSITORY } from './domain/repositories/search.repository'; import { ListingApprovedEventHandler } from './infrastructure/event-handlers/listing-approved.handler'; +import { ListingStatusChangedHandler } from './infrastructure/event-handlers/listing-status-changed.handler'; import { ListingIndexerService } from './infrastructure/services/listing-indexer.service'; import { TypesenseClientService } from './infrastructure/services/typesense-client.service'; import { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository'; @@ -27,6 +28,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler]; // Event handlers ListingApprovedEventHandler, + ListingStatusChangedHandler, // CQRS ...CommandHandlers, diff --git a/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts b/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts index 0b05c50..4120bbd 100644 --- a/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts +++ b/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts @@ -40,4 +40,56 @@ describe('AggregateRoot', () => { }); expect(agg.domainEvents).toHaveLength(1); }); + + it('should return uncommitted events without clearing via getUncommittedEvents()', () => { + const agg = new TestAggregate('agg-1'); + agg.doSomething(); + agg.doSomething(); + + const events = agg.getUncommittedEvents(); + expect(events).toHaveLength(2); + // Should not clear events + expect(agg.domainEvents).toHaveLength(2); + }); + + it('should return defensive copy from getUncommittedEvents()', () => { + const agg = new TestAggregate('agg-1'); + agg.doSomething(); + + const events = agg.getUncommittedEvents(); + events.push({ eventName: 'Fake', occurredAt: new Date(), aggregateId: 'x' }); + expect(agg.getUncommittedEvents()).toHaveLength(1); + }); + + it('should clear and return events via commit()', () => { + const agg = new TestAggregate('agg-1'); + agg.doSomething(); + agg.doSomething(); + agg.doSomething(); + + const events = agg.commit(); + expect(events).toHaveLength(3); + expect(agg.domainEvents).toHaveLength(0); + expect(agg.getUncommittedEvents()).toHaveLength(0); + }); + + it('commit() should behave identically to clearDomainEvents()', () => { + const agg1 = new TestAggregate('agg-1'); + const agg2 = new TestAggregate('agg-2'); + agg1.doSomething(); + agg2.doSomething(); + + const cleared = agg1.clearDomainEvents(); + const committed = agg2.commit(); + + expect(cleared).toHaveLength(committed.length); + expect(agg1.domainEvents).toHaveLength(0); + expect(agg2.domainEvents).toHaveLength(0); + }); + + it('should return empty arrays when no events exist', () => { + const agg = new TestAggregate('agg-1'); + expect(agg.getUncommittedEvents()).toHaveLength(0); + expect(agg.commit()).toHaveLength(0); + }); }); diff --git a/apps/api/src/modules/shared/domain/aggregate-root.ts b/apps/api/src/modules/shared/domain/aggregate-root.ts index 92d6180..2ef70a9 100644 --- a/apps/api/src/modules/shared/domain/aggregate-root.ts +++ b/apps/api/src/modules/shared/domain/aggregate-root.ts @@ -8,13 +8,33 @@ export abstract class AggregateRoot extends BaseEntity { return [...this._domainEvents]; } + /** + * Returns all domain events that have not yet been published. + * Use this to inspect pending events before committing. + */ + getUncommittedEvents(): DomainEvent[] { + return [...this._domainEvents]; + } + protected addDomainEvent(event: DomainEvent): void { this._domainEvents.push(event); } + /** + * Clears and returns all uncommitted domain events. + * Call this after persisting the aggregate and before publishing events. + */ clearDomainEvents(): DomainEvent[] { const events = [...this._domainEvents]; this._domainEvents = []; return events; } + + /** + * Alias for clearDomainEvents(). Marks all pending events as committed + * by clearing the internal event list and returning them for publishing. + */ + commit(): DomainEvent[] { + return this.clearDomainEvents(); + } } diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts index 586cf85..1f0e456 100644 --- a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { SubscriptionCancelledEvent } from '../events/subscription-cancelled.event'; import { SubscriptionCreatedEvent } from '../events/subscription-created.event'; +import { SubscriptionExpiredEvent } from '../events/subscription-expired.event'; +import { SubscriptionRenewedEvent } from '../events/subscription-renewed.event'; import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event'; describe('Subscription Domain Events', () => { @@ -45,4 +47,32 @@ describe('Subscription Domain Events', () => { expect(event.occurredAt).toBeInstanceOf(Date); }); }); + + describe('SubscriptionExpiredEvent', () => { + it('creates event with correct properties', () => { + const event = new SubscriptionExpiredEvent('sub-1', 'user-1', 'AGENT_PRO'); + + expect(event.eventName).toBe('subscription.expired'); + expect(event.aggregateId).toBe('sub-1'); + expect(event.userId).toBe('user-1'); + expect(event.planTier).toBe('AGENT_PRO'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); + + describe('SubscriptionRenewedEvent', () => { + it('creates event with correct properties', () => { + const start = new Date('2026-02-01'); + const end = new Date('2026-03-01'); + const event = new SubscriptionRenewedEvent('sub-1', 'user-1', 'PREMIUM', start, end); + + expect(event.eventName).toBe('subscription.renewed'); + expect(event.aggregateId).toBe('sub-1'); + expect(event.userId).toBe('user-1'); + expect(event.planTier).toBe('PREMIUM'); + expect(event.newPeriodStart).toEqual(start); + expect(event.newPeriodEnd).toEqual(end); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + }); }); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts index d31f278..29661ec 100644 --- a/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts @@ -81,16 +81,22 @@ describe('SubscriptionEntity', () => { expect(sub.status).toBe('PAST_DUE'); }); - it('marks expired', () => { + it('marks expired and emits SubscriptionExpiredEvent', () => { const sub = makeSub(); + sub.clearDomainEvents(); const expResult = sub.markExpired(); expect(expResult.isOk).toBe(true); expect(sub.status).toBe('EXPIRED'); + + const events = sub.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('subscription.expired'); }); - it('renews period', () => { + it('renews period and emits SubscriptionRenewedEvent', () => { const sub = makeSub(); sub.markPastDue(); + sub.clearDomainEvents(); const newStart = new Date('2026-02-01'); const newEnd = new Date('2026-03-01'); @@ -99,5 +105,9 @@ describe('SubscriptionEntity', () => { expect(sub.status).toBe('ACTIVE'); expect(sub.currentPeriodStart).toEqual(newStart); expect(sub.currentPeriodEnd).toEqual(newEnd); + + const events = sub.domainEvents; + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('subscription.renewed'); }); }); diff --git a/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts b/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts index 94d7e47..5af8dca 100644 --- a/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts +++ b/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts @@ -6,6 +6,8 @@ import { import { AggregateRoot, DomainException, ErrorCode, Result } from '@modules/shared'; import { SubscriptionCancelledEvent } from '../events/subscription-cancelled.event'; import { SubscriptionCreatedEvent } from '../events/subscription-created.event'; +import { SubscriptionExpiredEvent } from '../events/subscription-expired.event'; +import { SubscriptionRenewedEvent } from '../events/subscription-renewed.event'; import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event'; export interface SubscriptionProps { @@ -126,6 +128,10 @@ export class SubscriptionEntity extends AggregateRoot { } this._status = 'EXPIRED'; this.updatedAt = new Date(); + + this.addDomainEvent( + new SubscriptionExpiredEvent(this.id, this._userId, this._planTier), + ); return Result.ok(undefined); } @@ -149,6 +155,10 @@ export class SubscriptionEntity extends AggregateRoot { this._currentPeriodEnd = newEnd; this._status = 'ACTIVE'; this.updatedAt = new Date(); + + this.addDomainEvent( + new SubscriptionRenewedEvent(this.id, this._userId, this._planTier, newStart, newEnd), + ); } isActive(): boolean { diff --git a/apps/api/src/modules/subscriptions/domain/events/subscription-expired.event.ts b/apps/api/src/modules/subscriptions/domain/events/subscription-expired.event.ts new file mode 100644 index 0000000..6d06262 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/events/subscription-expired.event.ts @@ -0,0 +1,13 @@ +import { type PlanTier } from '@prisma/client'; +import { type DomainEvent } from '@modules/shared'; + +export class SubscriptionExpiredEvent implements DomainEvent { + readonly eventName = 'subscription.expired'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly planTier: PlanTier, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/domain/events/subscription-renewed.event.ts b/apps/api/src/modules/subscriptions/domain/events/subscription-renewed.event.ts new file mode 100644 index 0000000..27a995d --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/events/subscription-renewed.event.ts @@ -0,0 +1,15 @@ +import { type PlanTier } from '@prisma/client'; +import { type DomainEvent } from '@modules/shared'; + +export class SubscriptionRenewedEvent implements DomainEvent { + readonly eventName = 'subscription.renewed'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly planTier: PlanTier, + public readonly newPeriodStart: Date, + public readonly newPeriodEnd: Date, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/index.ts b/apps/api/src/modules/subscriptions/index.ts index c82b9e2..e4ff893 100644 --- a/apps/api/src/modules/subscriptions/index.ts +++ b/apps/api/src/modules/subscriptions/index.ts @@ -5,3 +5,5 @@ export { RequireQuota, QUOTA_METRIC_KEY } from './presentation/decorators/requir export { SubscriptionEntity, type SubscriptionProps } from './domain/entities/subscription.entity'; export { QuotaExceededEvent } from './domain/events/quota-exceeded.event'; export { SubscriptionCancelledEvent } from './domain/events/subscription-cancelled.event'; +export { SubscriptionExpiredEvent } from './domain/events/subscription-expired.event'; +export { SubscriptionRenewedEvent } from './domain/events/subscription-renewed.event';