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

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

View File

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

View File

@@ -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<string> {
}
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 {

View File

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

View File

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

View File

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