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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { NotificationsModule } from './notifications.module';
|
||||
export { SendNotificationCommand } from './application/commands/send-notification/send-notification.command';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user