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

View File

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

View File

@@ -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<string> {
}
this._status = 'REFUNDED';
this.updatedAt = new Date();
this.addDomainEvent(
new PaymentRefundedEvent(this.id, this._userId, this._provider, this._amount.value),
);
return Result.ok(undefined);
}
}

View File

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

View File

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