feat(api): complete domain event publishing with aggregate root pattern

- Add getUncommittedEvents() and commit() to AggregateRoot base class
- Create 6 new domain events: SubscriptionExpired, SubscriptionRenewed,
  ListingStatusChanged, UserKycUpdated, UserDeactivated, PaymentRefunded
- Wire events into entity state changes: SubscriptionEntity (markExpired,
  renewPeriod), ListingEntity (all transitions), UserEntity (KYC, deactivate),
  PaymentEntity (markRefunded)
- Add 7 new event listeners across notifications, admin, and search modules
  (25 total @OnEvent handlers)
- Fix ReviewDeletedListener to handle LISTING target type
- Restore watcher notifications in ListingSoldListener
- Update barrel exports and module registrations

Resolves: TEC-1564

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 10:22:20 +07:00
parent 35feccb529
commit 8179f1c16e
37 changed files with 613 additions and 36 deletions

View File

@@ -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');
});
});
});

View File

@@ -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', () => {

View File

@@ -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<ListingStatus, ListingStatus[]> = {
@@ -152,6 +153,11 @@ export class ListingEntity extends AggregateRoot<string> {
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));

View File

@@ -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,
) {}
}

View File

@@ -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';