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:
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user