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

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