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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

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

View File

@@ -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(

View File

@@ -1 +1,2 @@
export { ListingApprovedEventHandler } from './listing-approved.handler';
export { ListingStatusChangedHandler } from './listing-status-changed.handler';

View File

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

View File

@@ -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,

View File

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

View File

@@ -8,13 +8,33 @@ export abstract class AggregateRoot<TId = string> extends BaseEntity<TId> {
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();
}
}

View File

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

View File

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

View File

@@ -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<string> {
}
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<string> {
this._currentPeriodEnd = newEnd;
this._status = 'ACTIVE';
this.updatedAt = new Date();
this.addDomainEvent(
new SubscriptionRenewedEvent(this.id, this._userId, this._planTier, newStart, newEnd),
);
}
isActive(): boolean {

View File

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

View File

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

View File

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