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,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 },
});
});
});

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',
);
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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],
})