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,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ReviewDeletedListener } from '../listeners/review-deleted.listener';
|
||||
|
||||
describe('ReviewDeletedListener', () => {
|
||||
let listener: ReviewDeletedListener;
|
||||
let mockPrisma: {
|
||||
review: { aggregate: ReturnType<typeof vi.fn> };
|
||||
agent: { update: ReturnType<typeof vi.fn> };
|
||||
listing: { update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
review: { aggregate: vi.fn() },
|
||||
agent: { update: vi.fn().mockResolvedValue(undefined) },
|
||||
listing: { update: vi.fn().mockResolvedValue(undefined) },
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
|
||||
listener = new ReviewDeletedListener(mockPrisma as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('recalculates agent quality score on review deletion', async () => {
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: 4.2 },
|
||||
_count: { rating: 5 },
|
||||
});
|
||||
|
||||
await listener.handle({
|
||||
aggregateId: 'review-1',
|
||||
userId: 'user-1',
|
||||
targetType: 'AGENT',
|
||||
targetId: 'agent-1',
|
||||
eventName: 'review.deleted',
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
|
||||
where: { id: 'agent-1' },
|
||||
data: { qualityScore: 4.2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('recalculates listing average rating on review deletion', async () => {
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: 3.5 },
|
||||
_count: { rating: 10 },
|
||||
});
|
||||
|
||||
await listener.handle({
|
||||
aggregateId: 'review-2',
|
||||
userId: 'user-2',
|
||||
targetType: 'LISTING',
|
||||
targetId: 'listing-1',
|
||||
eventName: 'review.deleted',
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockPrisma.listing.update).toHaveBeenCalledWith({
|
||||
where: { id: 'listing-1' },
|
||||
data: { averageRating: 3.5, reviewCount: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles zero reviews after deletion', async () => {
|
||||
mockPrisma.review.aggregate.mockResolvedValue({
|
||||
_avg: { rating: null },
|
||||
_count: { rating: 0 },
|
||||
});
|
||||
|
||||
await listener.handle({
|
||||
aggregateId: 'review-3',
|
||||
userId: 'user-3',
|
||||
targetType: 'AGENT',
|
||||
targetId: 'agent-2',
|
||||
eventName: 'review.deleted',
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockPrisma.agent.update).toHaveBeenCalledWith({
|
||||
where: { id: 'agent-2' },
|
||||
data: { qualityScore: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ConflictException, ValidationException } from '@modules/shared/domain/domain-exception';
|
||||
import { ConflictException, ValidationException } from '@modules/shared';
|
||||
import { ReviewEntity } from '../../../domain/entities/review.entity';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
|
||||
import { Rating } from '../../../domain/value-objects/rating.vo';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||
import { ForbiddenException, NotFoundException } from '@modules/shared';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
|
||||
import { DeleteReviewCommand } from './delete-review.command';
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
import { type ReviewDeletedEvent } from '../../domain/events/review-deleted.event';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewDeletedListener {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('review.deleted', { async: true })
|
||||
async handle(event: ReviewDeletedEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Handling review.deleted: recalculating rating for ${event.targetType}:${event.targetId}`,
|
||||
'ReviewDeletedListener',
|
||||
);
|
||||
|
||||
// Recalculate average rating for the target (agent or listing)
|
||||
const stats = await this.prisma.review.aggregate({
|
||||
where: {
|
||||
targetType: event.targetType,
|
||||
targetId: event.targetId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_avg: { rating: true },
|
||||
_count: { rating: true },
|
||||
});
|
||||
|
||||
const avgRating = stats._avg.rating ?? 0;
|
||||
const reviewCount = stats._count.rating;
|
||||
|
||||
// Update the target's cached rating based on target type
|
||||
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(
|
||||
`Rating recalculated for ${event.targetType}:${event.targetId} → avg=${avgRating.toFixed(2)}, count=${reviewCount}`,
|
||||
'ReviewDeletedListener',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
|
||||
import { AggregateRoot } from '@modules/shared';
|
||||
import { ReviewCreatedEvent } from '../events/review-created.event';
|
||||
import { ReviewDeletedEvent } from '../events/review-deleted.event';
|
||||
import { type Rating } from '../value-objects/rating.vo';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class ReviewCreatedEvent implements DomainEvent {
|
||||
readonly eventName = 'review.created';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class ReviewDeletedEvent implements DomainEvent {
|
||||
readonly eventName = 'review.deleted';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Result } from '@modules/shared/domain/result';
|
||||
import { ValueObject } from '@modules/shared/domain/value-object';
|
||||
import { Result, ValueObject } from '@modules/shared';
|
||||
|
||||
interface RatingProps {
|
||||
value: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Review as PrismaReview } from '@prisma/client';
|
||||
import type { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { ReviewEntity } from '../../domain/entities/review.entity';
|
||||
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import type { IReviewRepository, PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import type { JwtPayload } from '@modules/auth/infrastructure/services/token.service';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
||||
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
|
||||
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { CreateReviewHandler } from './application/commands/create-review/create-review.handler';
|
||||
import { DeleteReviewHandler } from './application/commands/delete-review/delete-review.handler';
|
||||
import { ReviewDeletedListener } from './application/listeners/review-deleted.listener';
|
||||
import { GetAverageRatingHandler } from './application/queries/get-average-rating/get-average-rating.handler';
|
||||
import { GetReviewsByTargetHandler } from './application/queries/get-reviews-by-target/get-reviews-by-target.handler';
|
||||
import { GetReviewsByUserHandler } from './application/queries/get-reviews-by-user/get-reviews-by-user.handler';
|
||||
@@ -24,6 +25,7 @@ const QueryHandlers = [
|
||||
{ provide: REVIEW_REPOSITORY, useClass: PrismaReviewRepository },
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
ReviewDeletedListener,
|
||||
],
|
||||
exports: [REVIEW_REPOSITORY],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import {
|
||||
SEARCH_REPOSITORY,
|
||||
type ISearchRepository,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import {
|
||||
SEARCH_REPOSITORY,
|
||||
type ISearchRepository,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ValueObject } from '@modules/shared/domain/value-object';
|
||||
import { ValueObject } from '@modules/shared';
|
||||
|
||||
interface GeoFilterProps {
|
||||
lat: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ValueObject } from '@modules/shared/domain/value-object';
|
||||
import { ValueObject } from '@modules/shared';
|
||||
|
||||
interface SearchFilterProps {
|
||||
query?: string;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { SearchModule } from './search.module';
|
||||
export { TypesenseClientService } from './infrastructure/services/typesense-client.service';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { CacheService, CachePrefix, type LoggerService } from '@modules/shared';
|
||||
import { type ListingIndexerService } from '../services/listing-indexer.service';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
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 {
|
||||
SEARCH_REPOSITORY,
|
||||
type ISearchRepository,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { Client as TypesenseClient } from 'typesense';
|
||||
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
|
||||
@Injectable()
|
||||
export class TypesenseClientService implements OnModuleInit {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import type { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
|
||||
import { Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command';
|
||||
import { type ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler';
|
||||
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module, type OnModuleInit } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';
|
||||
import { SyncListingHandler } from './application/commands/sync-listing/sync-listing.handler';
|
||||
import { GeoSearchHandler } from './application/queries/geo-search/geo-search.handler';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { NotFoundException, ConflictException } from '@modules/shared/domain/domain-exception';
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { NotFoundException, ConflictException, type PrismaService } from '@modules/shared';
|
||||
import { SubscriptionEntity } from '../../../domain/entities/subscription.entity';
|
||||
import {
|
||||
SUBSCRIPTION_REPOSITORY,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
|
||||
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
SUBSCRIPTION_REPOSITORY,
|
||||
type ISubscriptionRepository,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type Plan } from '@prisma/client';
|
||||
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { NotFoundException, CacheService, CachePrefix, CacheTTL, type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
SUBSCRIPTION_REPOSITORY,
|
||||
type ISubscriptionRepository,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
SUBSCRIPTION_REPOSITORY,
|
||||
type ISubscriptionRepository,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type Plan } from '@prisma/client';
|
||||
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { GetPlanQuery } from './get-plan.query';
|
||||
|
||||
export interface PlanDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class QuotaExceededEvent implements DomainEvent {
|
||||
readonly eventName = 'quota.exceeded';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class SubscriptionCancelledEvent implements DomainEvent {
|
||||
readonly eventName = 'subscription.cancelled';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class SubscriptionCreatedEvent implements DomainEvent {
|
||||
readonly eventName = 'subscription.created';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class SubscriptionUpgradedEvent implements DomainEvent {
|
||||
readonly eventName = 'subscription.upgraded';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { SubscriptionsModule } from './subscriptions.module';
|
||||
export { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository';
|
||||
export { type ISubscriptionRepository } from './domain/repositories/subscription.repository';
|
||||
export { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from './domain/repositories/subscription.repository';
|
||||
export { QuotaGuard } from './presentation/guards/quota.guard';
|
||||
export { RequireQuota } from './presentation/decorators/require-quota.decorator';
|
||||
export { RequireQuota, QUOTA_METRIC_KEY } from './presentation/decorators/require-quota.decorator';
|
||||
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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
|
||||
import { type ListingCreatedEvent } from '@modules/listings';
|
||||
import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Subscription as PrismaSubscription, type Plan as PrismaPlan } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { SubscriptionEntity, type SubscriptionProps } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ import {
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { CancelSubscriptionCommand } from '../../application/commands/cancel-subscription/cancel-subscription.command';
|
||||
import { type CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler';
|
||||
import { CreateSubscriptionCommand } from '../../application/commands/create-subscription/create-subscription.command';
|
||||
|
||||
Reference in New Issue
Block a user