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,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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user