feat(api): improve notifications, reviews, search, and subscriptions modules

- Add listing-sold event listener with spec for notifications
- Add review-deleted event listener with spec for reviews
- Improve search handlers with proper Typesense client injection
- Improve subscription handlers with ConfigService and quota tracking

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:43:39 +07:00
parent f15e98a33b
commit e927385ed5
54 changed files with 356 additions and 82 deletions

View File

@@ -0,0 +1,86 @@
import { ListingSoldListener } from '../listeners/listing-sold.listener';
describe('ListingSoldListener', () => {
let listener: ListingSoldListener;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
savedListing: { findMany: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) };
mockPrisma = {
listing: { findUnique: vi.fn() },
savedListing: { findMany: vi.fn().mockResolvedValue([]) },
};
mockLogger = { log: vi.fn(), warn: vi.fn() };
listener = new ListingSoldListener(
mockCommandBus as any,
mockPrisma as any,
mockLogger as any,
);
});
it('notifies seller when listing is sold', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ đẹp' },
seller: { id: 'seller-1', email: 'seller@example.com' },
});
await listener.handle({
aggregateId: 'listing-1',
propertyId: 'prop-1',
finalStatus: 'SOLD',
eventName: 'listing.sold',
occurredAt: new Date(),
});
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'seller-1',
channel: 'EMAIL',
templateKey: 'listing.sold',
}),
);
});
it('notifies watchers when listing is sold', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
property: { title: 'Căn hộ đẹp' },
seller: { id: 'seller-1', email: 'seller@example.com' },
});
mockPrisma.savedListing.findMany.mockResolvedValue([
{ user: { id: 'watcher-1', email: 'watcher@example.com' } },
]);
await listener.handle({
aggregateId: 'listing-1',
propertyId: 'prop-1',
finalStatus: 'SOLD',
eventName: 'listing.sold',
occurredAt: new Date(),
});
// Seller + 1 watcher = 2 notifications
expect(mockCommandBus.execute).toHaveBeenCalledTimes(2);
});
it('skips notification when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
await listener.handle({
aggregateId: 'nonexistent',
propertyId: 'prop-1',
finalStatus: 'SOLD',
eventName: 'listing.sold',
occurredAt: new Date(),
});
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,6 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type EventBusService } from '@modules/shared/infrastructure/event-bus.service';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type EventBusService, type LoggerService } from '@modules/shared';
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type AgentVerifiedEvent } from '@modules/auth/domain/events/agent-verified.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type AgentVerifiedEvent } from '@modules/auth';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
export interface InquiryReceivedEvent {

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingApprovedEvent } from '@modules/admin/domain/events/listing-approved.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type ListingApprovedEvent } from '@modules/admin';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingRejectedEvent } from '@modules/admin/domain/events/listing-rejected.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type ListingRejectedEvent } from '@modules/admin';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingSoldEvent } from '@modules/listings';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class ListingSoldListener {
constructor(
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.sold', { async: true })
async handle(event: ListingSoldEvent): Promise<void> {
this.logger.log(`Handling listing.sold for ${event.aggregateId}`, 'ListingSoldListener');
const listing = await this.prisma.listing.findUnique({
where: { id: event.aggregateId },
include: {
property: { select: { title: true } },
seller: { select: { id: true, email: true } },
},
});
if (!listing) return;
// Notify the seller
if (listing.seller.email) {
await this.commandBus.execute(
new SendNotificationCommand(
listing.seller.id,
'EMAIL',
'listing.sold',
{ listingTitle: listing.property.title, finalStatus: event.finalStatus },
listing.seller.email,
),
);
}
// 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}`,
'ListingSoldListener',
);
}
}

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type PaymentCompletedEvent } from '@modules/payments/domain/events/payment-completed.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PaymentCompletedEvent } from '@modules/payments';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type QuotaExceededEvent } from '@modules/subscriptions/domain/events/quota-exceeded.event';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type QuotaExceededEvent } from '@modules/subscriptions';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type SubscriptionCancelledEvent } from '@modules/subscriptions/domain/events/subscription-cancelled.event';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { type SubscriptionCancelledEvent } from '@modules/subscriptions';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type UserRegisteredEvent } from '@modules/auth/domain/events/user-registered.event';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type UserRegisteredEvent } from '@modules/auth';
import { type LoggerService, type PrismaService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()

View File

@@ -1,4 +1,4 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type DomainEvent } from '@modules/shared';
import { type NotificationChannel } from '../value-objects/notification-channel.vo';
export class NotificationSentEvent implements DomainEvent {

View File

@@ -1 +1,2 @@
export { NotificationsModule } from './notifications.module';
export { SendNotificationCommand } from './application/commands/send-notification/send-notification.command';

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { type NotificationPreferenceEntity } from '../../domain/entities/notification-preference.entity';
import { type INotificationPreferenceRepository } from '../../domain/repositories/notification-preference.repository';
import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { type PrismaService } from '@modules/shared';
import { type NotificationEntity, type NotificationStatus } from '../../domain/entities/notification.entity';
import {
type INotificationRepository,

View File

@@ -1,6 +1,6 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
export interface SendEmailDto {
to: string;
@@ -41,11 +41,11 @@ export class EmailService implements OnModuleInit {
html: dto.html,
});
this.logger.log(`Email sent to ${dto.to}: ${info.messageId}`, 'EmailService');
this.logger.log(`Email sent successfully: ${info.messageId}`, 'EmailService');
return { messageId: info.messageId };
} catch (error) {
this.logger.error(
`Failed to send email to ${dto.to}: ${error instanceof Error ? error.message : String(error)}`,
`Failed to send email: ${error instanceof Error ? error.message : String(error)}`,
'EmailService',
);
throw error;

View File

@@ -6,7 +6,7 @@ import {
messaging,
type ServiceAccount,
} from 'firebase-admin';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { type LoggerService } from '@modules/shared';
export interface SendPushDto {
token: string;

View File

@@ -5,6 +5,7 @@ import { AgentVerifiedListener } from './application/listeners/agent-verified.li
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
import { ListingApprovedListener } from './application/listeners/listing-approved.listener';
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 { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
import { SubscriptionExpiringListener } from './application/listeners/subscription-expiring.listener';
@@ -29,6 +30,7 @@ const EventListeners = [
PaymentCompletedListener,
SubscriptionExpiringListener,
InquiryReceivedListener,
ListingSoldListener,
];
@Module({

View File

@@ -13,8 +13,7 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger';
import { NotificationChannel as PrismaChannel } from '@prisma/client';
import { IsBoolean, IsEnum, IsString } from 'class-validator';
import { type JwtPayload } from '@modules/auth';
import { CurrentUser } from '@modules/auth/presentation/decorators';
import { CurrentUser, type JwtPayload } from '@modules/auth';
import {
NOTIFICATION_REPOSITORY,
type INotificationRepository,