feat(api): add reviews module with CRUD endpoints and CQRS

Implement polymorphic reviews system supporting any target type (agent,
property, etc.) with DDD/CQRS architecture following existing patterns.

Endpoints:
- POST /api/reviews — create review (authenticated)
- GET /api/reviews?targetType=&targetId= — list reviews by target
- GET /api/reviews/stats?targetType=&targetId= — aggregate rating stats
- GET /api/reviews/me — list authenticated user's reviews
- DELETE /api/reviews/:id — delete own review

Business rules: 1-5 rating validation, self-review prevention, one
review per user per target. Includes 15 unit tests for all handlers.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 00:02:09 +07:00
parent 0c26dd85ef
commit 2fc2624fa7
28 changed files with 1062 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import { McpIntegrationModule } from '@modules/mcp';
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
import { NotificationsModule } from '@modules/notifications';
import { PaymentsModule } from '@modules/payments';
import { ReviewsModule } from '@modules/reviews';
import { SearchModule } from '@modules/search';
import { SharedModule } from '@modules/shared';
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
@@ -26,6 +27,7 @@ import { AppController } from './app.controller';
SharedModule,
AuthModule,
ListingsModule,
ReviewsModule,
SearchModule,
NotificationsModule,
PaymentsModule,

View File

@@ -0,0 +1,89 @@
import type { EventBus } from '@nestjs/cqrs';
import { ReviewEntity } from '../../domain/entities/review.entity';
import type { IReviewRepository } from '../../domain/repositories/review.repository';
import { Rating } from '../../domain/value-objects/rating.vo';
import { CreateReviewCommand } from '../commands/create-review/create-review.command';
import { CreateReviewHandler } from '../commands/create-review/create-review.handler';
describe('CreateReviewHandler', () => {
let handler: CreateReviewHandler;
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockReviewRepo = {
findById: vi.fn(),
findByUserAndTarget: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
findByTarget: vi.fn(),
findByUserId: vi.fn(),
getStats: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
handler = new CreateReviewHandler(
mockReviewRepo as any,
mockEventBus as unknown as EventBus,
);
});
it('creates a review successfully', async () => {
mockReviewRepo.findByUserAndTarget.mockResolvedValue(null);
mockReviewRepo.save.mockResolvedValue(undefined);
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 5, 'Great!');
const result = await handler.execute(command);
expect(result.rating).toBe(5);
expect(result.targetType).toBe('agent');
expect(result.targetId).toBe('agent-1');
expect(result.id).toBeDefined();
expect(mockReviewRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws ValidationException for invalid rating', async () => {
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 0, null);
await expect(handler.execute(command)).rejects.toThrow();
});
it('throws ValidationException for rating > 5', async () => {
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 6, null);
await expect(handler.execute(command)).rejects.toThrow();
});
it('prevents self-review for user target type', async () => {
const command = new CreateReviewCommand('user-1', 'user', 'user-1', 5, null);
await expect(handler.execute(command)).rejects.toThrow('Không thể tự đánh giá bản thân');
});
it('prevents duplicate review', async () => {
const existingReview = new ReviewEntity('existing-1', {
userId: 'user-1',
targetType: 'agent',
targetId: 'agent-1',
rating: Rating.create(4).unwrap(),
comment: null,
});
mockReviewRepo.findByUserAndTarget.mockResolvedValue(existingReview);
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 5, null);
await expect(handler.execute(command)).rejects.toThrow('Bạn đã đánh giá mục tiêu này rồi');
});
it('allows review with null comment', async () => {
mockReviewRepo.findByUserAndTarget.mockResolvedValue(null);
mockReviewRepo.save.mockResolvedValue(undefined);
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 3, null);
const result = await handler.execute(command);
expect(result.rating).toBe(3);
});
});

View File

@@ -0,0 +1,66 @@
import type { EventBus } from '@nestjs/cqrs';
import { ReviewEntity } from '../../domain/entities/review.entity';
import type { IReviewRepository } from '../../domain/repositories/review.repository';
import { Rating } from '../../domain/value-objects/rating.vo';
import { DeleteReviewCommand } from '../commands/delete-review/delete-review.command';
import { DeleteReviewHandler } from '../commands/delete-review/delete-review.handler';
describe('DeleteReviewHandler', () => {
let handler: DeleteReviewHandler;
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
const mockReview = new ReviewEntity('review-1', {
userId: 'user-1',
targetType: 'agent',
targetId: 'agent-1',
rating: Rating.create(4).unwrap(),
comment: 'Good service',
});
beforeEach(() => {
mockReviewRepo = {
findById: vi.fn(),
findByUserAndTarget: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
findByTarget: vi.fn(),
findByUserId: vi.fn(),
getStats: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
handler = new DeleteReviewHandler(
mockReviewRepo as any,
mockEventBus as unknown as EventBus,
);
});
it('deletes own review successfully', async () => {
mockReviewRepo.findById.mockResolvedValue(mockReview);
mockReviewRepo.delete.mockResolvedValue(undefined);
const command = new DeleteReviewCommand('review-1', 'user-1');
await handler.execute(command);
expect(mockReviewRepo.delete).toHaveBeenCalledWith('review-1');
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when review not found', async () => {
mockReviewRepo.findById.mockResolvedValue(null);
const command = new DeleteReviewCommand('nonexistent', 'user-1');
await expect(handler.execute(command)).rejects.toThrow('Review');
});
it('throws ForbiddenException when deleting another user review', async () => {
mockReviewRepo.findById.mockResolvedValue(mockReview);
const command = new DeleteReviewCommand('review-1', 'user-2');
await expect(handler.execute(command)).rejects.toThrow('Bạn chỉ có thể xóa đánh giá của chính mình');
});
});

View File

@@ -0,0 +1,59 @@
import { type IReviewRepository } from '../../domain/repositories/review.repository';
import { GetAverageRatingHandler } from '../queries/get-average-rating/get-average-rating.handler';
import { GetAverageRatingQuery } from '../queries/get-average-rating/get-average-rating.query';
describe('GetAverageRatingHandler', () => {
let handler: GetAverageRatingHandler;
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
const mockStats = {
targetType: 'agent',
targetId: 'agent-1',
averageRating: 4.3,
totalReviews: 10,
distribution: { 1: 0, 2: 1, 3: 1, 4: 3, 5: 5 },
};
beforeEach(() => {
mockReviewRepo = {
findById: vi.fn(),
findByUserAndTarget: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
findByTarget: vi.fn(),
findByUserId: vi.fn(),
getStats: vi.fn(),
};
handler = new GetAverageRatingHandler(mockReviewRepo as any);
});
it('returns rating stats for a target', async () => {
mockReviewRepo.getStats.mockResolvedValue(mockStats);
const query = new GetAverageRatingQuery('agent', 'agent-1');
const result = await handler.execute(query);
expect(result).toEqual(mockStats);
expect(result.averageRating).toBe(4.3);
expect(result.totalReviews).toBe(10);
expect(mockReviewRepo.getStats).toHaveBeenCalledWith('agent', 'agent-1');
});
it('returns zero stats when no reviews exist', async () => {
const emptyStats = {
targetType: 'property',
targetId: 'prop-1',
averageRating: 0,
totalReviews: 0,
distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
};
mockReviewRepo.getStats.mockResolvedValue(emptyStats);
const query = new GetAverageRatingQuery('property', 'prop-1');
const result = await handler.execute(query);
expect(result.averageRating).toBe(0);
expect(result.totalReviews).toBe(0);
});
});

View File

@@ -0,0 +1,60 @@
import { type IReviewRepository } from '../../domain/repositories/review.repository';
import { GetReviewsByTargetHandler } from '../queries/get-reviews-by-target/get-reviews-by-target.handler';
import { GetReviewsByTargetQuery } from '../queries/get-reviews-by-target/get-reviews-by-target.query';
describe('GetReviewsByTargetHandler', () => {
let handler: GetReviewsByTargetHandler;
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
const mockPaginatedResult = {
data: [
{
id: 'review-1',
userId: 'user-1',
userName: 'Nguyen Van A',
targetType: 'agent',
targetId: 'agent-1',
rating: 5,
comment: 'Excellent!',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
beforeEach(() => {
mockReviewRepo = {
findById: vi.fn(),
findByUserAndTarget: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
findByTarget: vi.fn(),
findByUserId: vi.fn(),
getStats: vi.fn(),
};
handler = new GetReviewsByTargetHandler(mockReviewRepo as any);
});
it('returns paginated reviews for a target', async () => {
mockReviewRepo.findByTarget.mockResolvedValue(mockPaginatedResult);
const query = new GetReviewsByTargetQuery('agent', 'agent-1', 1, 20);
const result = await handler.execute(query);
expect(result).toEqual(mockPaginatedResult);
expect(mockReviewRepo.findByTarget).toHaveBeenCalledWith('agent', 'agent-1', 1, 20);
});
it('passes custom pagination params', async () => {
mockReviewRepo.findByTarget.mockResolvedValue({ ...mockPaginatedResult, page: 2 });
const query = new GetReviewsByTargetQuery('property', 'prop-1', 2, 10);
await handler.execute(query);
expect(mockReviewRepo.findByTarget).toHaveBeenCalledWith('property', 'prop-1', 2, 10);
});
});

View File

@@ -0,0 +1,51 @@
import { type IReviewRepository } from '../../domain/repositories/review.repository';
import { GetReviewsByUserHandler } from '../queries/get-reviews-by-user/get-reviews-by-user.handler';
import { GetReviewsByUserQuery } from '../queries/get-reviews-by-user/get-reviews-by-user.query';
describe('GetReviewsByUserHandler', () => {
let handler: GetReviewsByUserHandler;
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
const mockPaginatedResult = {
data: [
{
id: 'review-1',
userId: 'user-1',
userName: 'Nguyen Van A',
targetType: 'agent',
targetId: 'agent-1',
rating: 5,
comment: 'Very good',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
beforeEach(() => {
mockReviewRepo = {
findById: vi.fn(),
findByUserAndTarget: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
findByTarget: vi.fn(),
findByUserId: vi.fn(),
getStats: vi.fn(),
};
handler = new GetReviewsByUserHandler(mockReviewRepo as any);
});
it('returns paginated reviews for a user', async () => {
mockReviewRepo.findByUserId.mockResolvedValue(mockPaginatedResult);
const query = new GetReviewsByUserQuery('user-1', 1, 20);
const result = await handler.execute(query);
expect(result).toEqual(mockPaginatedResult);
expect(mockReviewRepo.findByUserId).toHaveBeenCalledWith('user-1', 1, 20);
});
});

View File

@@ -0,0 +1,9 @@
export class CreateReviewCommand {
constructor(
public readonly userId: string,
public readonly targetType: string,
public readonly targetId: string,
public readonly rating: number,
public readonly comment: string | null,
) {}
}

View File

@@ -0,0 +1,78 @@
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 { ReviewEntity } from '../../../domain/entities/review.entity';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { Rating } from '../../../domain/value-objects/rating.vo';
import { CreateReviewCommand } from './create-review.command';
export interface CreateReviewResult {
id: string;
rating: number;
targetType: string;
targetId: string;
createdAt: string;
}
@CommandHandler(CreateReviewCommand)
export class CreateReviewHandler implements ICommandHandler<CreateReviewCommand> {
private readonly logger = new Logger(CreateReviewHandler.name);
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: CreateReviewCommand): Promise<CreateReviewResult> {
// Validate rating value object
const ratingResult = Rating.create(command.rating);
if (ratingResult.isErr) {
throw new ValidationException(ratingResult.unwrapErr());
}
const rating = ratingResult.unwrap();
// Prevent self-review
if (command.userId === command.targetId && command.targetType === 'user') {
throw new ValidationException('Không thể tự đánh giá bản thân');
}
// One review per user per target
const existing = await this.reviewRepo.findByUserAndTarget(
command.userId,
command.targetType,
command.targetId,
);
if (existing) {
throw new ConflictException('Bạn đã đánh giá mục tiêu này rồi');
}
const id = createId();
const review = ReviewEntity.createNew(
id,
command.userId,
command.targetType,
command.targetId,
rating,
command.comment,
);
await this.reviewRepo.save(review);
// Publish domain events
const events = review.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Review ${id} created by user ${command.userId} for ${command.targetType}:${command.targetId}`);
return {
id,
rating: rating.value,
targetType: command.targetType,
targetId: command.targetId,
createdAt: review.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,6 @@
export class DeleteReviewCommand {
constructor(
public readonly reviewId: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,37 @@
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 { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { DeleteReviewCommand } from './delete-review.command';
@CommandHandler(DeleteReviewCommand)
export class DeleteReviewHandler implements ICommandHandler<DeleteReviewCommand> {
private readonly logger = new Logger(DeleteReviewHandler.name);
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: DeleteReviewCommand): Promise<void> {
const review = await this.reviewRepo.findById(command.reviewId);
if (!review) {
throw new NotFoundException('Review', command.reviewId);
}
if (review.userId !== command.userId) {
throw new ForbiddenException('Bạn chỉ có thể xóa đánh giá của chính mình');
}
review.markDeleted();
await this.reviewRepo.delete(command.reviewId);
// Publish domain events
const events = review.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(`Review ${command.reviewId} deleted by user ${command.userId}`);
}
}

View File

@@ -0,0 +1,16 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type ReviewStatsData } from '../../../domain/repositories/review-read.dto';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { GetAverageRatingQuery } from './get-average-rating.query';
@QueryHandler(GetAverageRatingQuery)
export class GetAverageRatingHandler implements IQueryHandler<GetAverageRatingQuery> {
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
) {}
async execute(query: GetAverageRatingQuery): Promise<ReviewStatsData> {
return this.reviewRepo.getStats(query.targetType, query.targetId);
}
}

View File

@@ -0,0 +1,6 @@
export class GetAverageRatingQuery {
constructor(
public readonly targetType: string,
public readonly targetId: string,
) {}
}

View File

@@ -0,0 +1,21 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type ReviewItemData } from '../../../domain/repositories/review-read.dto';
import { REVIEW_REPOSITORY, type IReviewRepository, type PaginatedResult } from '../../../domain/repositories/review.repository';
import { GetReviewsByTargetQuery } from './get-reviews-by-target.query';
@QueryHandler(GetReviewsByTargetQuery)
export class GetReviewsByTargetHandler implements IQueryHandler<GetReviewsByTargetQuery> {
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
) {}
async execute(query: GetReviewsByTargetQuery): Promise<PaginatedResult<ReviewItemData>> {
return this.reviewRepo.findByTarget(
query.targetType,
query.targetId,
query.page,
query.limit,
);
}
}

View File

@@ -0,0 +1,8 @@
export class GetReviewsByTargetQuery {
constructor(
public readonly targetType: string,
public readonly targetId: string,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,16 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type ReviewItemData } from '../../../domain/repositories/review-read.dto';
import { REVIEW_REPOSITORY, type IReviewRepository, type PaginatedResult } from '../../../domain/repositories/review.repository';
import { GetReviewsByUserQuery } from './get-reviews-by-user.query';
@QueryHandler(GetReviewsByUserQuery)
export class GetReviewsByUserHandler implements IQueryHandler<GetReviewsByUserQuery> {
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
) {}
async execute(query: GetReviewsByUserQuery): Promise<PaginatedResult<ReviewItemData>> {
return this.reviewRepo.findByUserId(query.userId, query.page, query.limit);
}
}

View File

@@ -0,0 +1,7 @@
export class GetReviewsByUserQuery {
constructor(
public readonly userId: string,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,63 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { ReviewCreatedEvent } from '../events/review-created.event';
import { ReviewDeletedEvent } from '../events/review-deleted.event';
import { type Rating } from '../value-objects/rating.vo';
export interface ReviewProps {
userId: string;
targetType: string;
targetId: string;
rating: Rating;
comment: string | null;
}
export class ReviewEntity extends AggregateRoot<string> {
private _userId: string;
private _targetType: string;
private _targetId: string;
private _rating: Rating;
private _comment: string | null;
constructor(id: string, props: ReviewProps, createdAt?: Date) {
super(id, createdAt);
this._userId = props.userId;
this._targetType = props.targetType;
this._targetId = props.targetId;
this._rating = props.rating;
this._comment = props.comment;
}
get userId(): string { return this._userId; }
get targetType(): string { return this._targetType; }
get targetId(): string { return this._targetId; }
get rating(): Rating { return this._rating; }
get comment(): string | null { return this._comment; }
static createNew(
id: string,
userId: string,
targetType: string,
targetId: string,
rating: Rating,
comment: string | null,
): ReviewEntity {
const review = new ReviewEntity(id, {
userId,
targetType,
targetId,
rating,
comment,
});
review.addDomainEvent(
new ReviewCreatedEvent(id, userId, targetType, targetId, rating.value),
);
return review;
}
markDeleted(): void {
this.addDomainEvent(
new ReviewDeletedEvent(this.id, this._userId, this._targetType, this._targetId),
);
}
}

View File

@@ -0,0 +1,14 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
export class ReviewCreatedEvent implements DomainEvent {
readonly eventName = 'review.created';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly userId: string,
public readonly targetType: string,
public readonly targetId: string,
public readonly rating: number,
) {}
}

View File

@@ -0,0 +1,13 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
export class ReviewDeletedEvent implements DomainEvent {
readonly eventName = 'review.deleted';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly userId: string,
public readonly targetType: string,
public readonly targetId: string,
) {}
}

View File

@@ -0,0 +1,18 @@
export interface ReviewItemData {
id: string;
userId: string;
userName: string | null;
targetType: string;
targetId: string;
rating: number;
comment: string | null;
createdAt: string;
}
export interface ReviewStatsData {
targetType: string;
targetId: string;
averageRating: number;
totalReviews: number;
distribution: Record<number, number>;
}

View File

@@ -0,0 +1,22 @@
import { type ReviewEntity } from '../entities/review.entity';
import { type ReviewItemData, type ReviewStatsData } from './review-read.dto';
export const REVIEW_REPOSITORY = Symbol('REVIEW_REPOSITORY');
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface IReviewRepository {
findById(id: string): Promise<ReviewEntity | null>;
findByUserAndTarget(userId: string, targetType: string, targetId: string): Promise<ReviewEntity | null>;
save(review: ReviewEntity): Promise<void>;
delete(id: string): Promise<void>;
findByTarget(targetType: string, targetId: string, page: number, limit: number): Promise<PaginatedResult<ReviewItemData>>;
findByUserId(userId: string, page: number, limit: number): Promise<PaginatedResult<ReviewItemData>>;
getStats(targetType: string, targetId: string): Promise<ReviewStatsData>;
}

View File

@@ -0,0 +1,17 @@
import { Result } from '@modules/shared/domain/result';
import { ValueObject } from '@modules/shared/domain/value-object';
interface RatingProps {
value: number;
}
export class Rating extends ValueObject<RatingProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<Rating, string> {
if (!Number.isInteger(value) || value < 1 || value > 5) {
return Result.err('Đánh giá phải từ 1 đến 5 sao');
}
return Result.ok(new Rating({ value }));
}
}

View File

@@ -0,0 +1,2 @@
export { ReviewsModule } from './reviews.module';
export { REVIEW_REPOSITORY, type IReviewRepository } from './domain/repositories/review.repository';

View File

@@ -0,0 +1,161 @@
import { Injectable } from '@nestjs/common';
import type { Review as PrismaReview } from '@prisma/client';
import type { PrismaService } from '@modules/shared/infrastructure/prisma.service';
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';
import { Rating } from '../../domain/value-objects/rating.vo';
@Injectable()
export class PrismaReviewRepository implements IReviewRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<ReviewEntity | null> {
const review = await this.prisma.review.findUnique({ where: { id } });
return review ? this.toDomain(review) : null;
}
async findByUserAndTarget(
userId: string,
targetType: string,
targetId: string,
): Promise<ReviewEntity | null> {
const review = await this.prisma.review.findFirst({
where: { userId, targetType, targetId },
});
return review ? this.toDomain(review) : null;
}
async save(entity: ReviewEntity): Promise<void> {
await this.prisma.review.create({
data: {
id: entity.id,
userId: entity.userId,
targetType: entity.targetType,
targetId: entity.targetId,
rating: entity.rating.value,
comment: entity.comment,
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.review.delete({ where: { id } });
}
async findByTarget(
targetType: string,
targetId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ReviewItemData>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { targetType, targetId };
const [data, total] = await Promise.all([
this.prisma.review.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, fullName: true } } },
}),
this.prisma.review.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.user.fullName,
targetType: r.targetType,
targetId: r.targetId,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async findByUserId(
userId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ReviewItemData>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { userId };
const [data, total] = await Promise.all([
this.prisma.review.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, fullName: true } } },
}),
this.prisma.review.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.user.fullName,
targetType: r.targetType,
targetId: r.targetId,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async getStats(targetType: string, targetId: string): Promise<ReviewStatsData> {
const reviews = await this.prisma.review.findMany({
where: { targetType, targetId },
select: { rating: true },
});
const totalReviews = reviews.length;
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
let sum = 0;
for (const r of reviews) {
sum += r.rating;
distribution[r.rating] = (distribution[r.rating] ?? 0) + 1;
}
return {
targetType,
targetId,
averageRating: totalReviews > 0 ? Math.round((sum / totalReviews) * 10) / 10 : 0,
totalReviews,
distribution,
};
}
private toDomain(raw: PrismaReview): ReviewEntity {
const rating = Rating.create(raw.rating).unwrap();
return new ReviewEntity(
raw.id,
{
userId: raw.userId,
targetType: raw.targetType,
targetId: raw.targetId,
rating,
comment: raw.comment,
},
raw.createdAt,
);
}
}

View File

@@ -0,0 +1,123 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
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 { 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';
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
import type { PaginatedResult } from '../../domain/repositories/review.repository';
import type { CreateReviewDto } from '../dto/create-review.dto';
import type { ListReviewsByTargetDto, ReviewStatsDto } from '../dto/list-reviews.dto';
@ApiTags('reviews')
@Controller('reviews')
export class ReviewsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create a review' })
@ApiResponse({ status: 201, description: 'Review created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Already reviewed this target' })
@UseGuards(JwtAuthGuard)
@Post()
async createReview(
@Body() dto: CreateReviewDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateReviewResult> {
return this.commandBus.execute(
new CreateReviewCommand(
user.sub,
dto.targetType,
dto.targetId,
dto.rating,
dto.comment ?? null,
),
);
}
@ApiOperation({ summary: 'List reviews by target' })
@ApiResponse({ status: 200, description: 'Paginated list of reviews' })
@Get()
async getReviewsByTarget(
@Query() dto: ListReviewsByTargetDto,
): Promise<PaginatedResult<ReviewItemData>> {
return this.queryBus.execute(
new GetReviewsByTargetQuery(
dto.targetType,
dto.targetId,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Get aggregate rating stats for a target' })
@ApiResponse({ status: 200, description: 'Rating statistics' })
@Get('stats')
async getStats(
@Query() dto: ReviewStatsDto,
): Promise<ReviewStatsData> {
return this.queryBus.execute(
new GetAverageRatingQuery(dto.targetType, dto.targetId),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get reviews by authenticated user' })
@ApiResponse({ status: 200, description: 'Paginated list of user reviews' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('me')
async getMyReviews(
@CurrentUser() user: JwtPayload,
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<PaginatedResult<ReviewItemData>> {
return this.queryBus.execute(
new GetReviewsByUserQuery(user.sub, page ?? 1, limit ?? 20),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Delete own review' })
@ApiParam({ name: 'id', description: 'Review ID' })
@ApiResponse({ status: 200, description: 'Review deleted' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Cannot delete another user\'s review' })
@ApiResponse({ status: 404, description: 'Review not found' })
@UseGuards(JwtAuthGuard)
@Delete(':id')
async deleteReview(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ deleted: boolean }> {
await this.commandBus.execute(new DeleteReviewCommand(id, user.sub));
return { deleted: true };
}
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateReviewDto {
@ApiProperty({ example: 'agent', description: 'Target entity type (e.g. agent, property)' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
@ApiProperty({ example: 5, description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
@IsInt()
@Min(1)
@Max(5)
rating!: number;
@ApiPropertyOptional({ example: 'Dịch vụ rất tốt!', description: 'Optional review comment' })
@IsOptional()
@IsString()
@MaxLength(2000)
comment?: string;
}

View File

@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListReviewsByTargetDto {
@ApiProperty({ example: 'agent', description: 'Target entity type' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
export class ReviewStatsDto {
@ApiProperty({ example: 'agent', description: 'Target entity type' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
}

View File

@@ -0,0 +1,30 @@
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 { 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';
import { REVIEW_REPOSITORY } from './domain/repositories/review.repository';
import { PrismaReviewRepository } from './infrastructure/repositories/prisma-review.repository';
import { ReviewsController } from './presentation/controllers/reviews.controller';
const CommandHandlers = [CreateReviewHandler, DeleteReviewHandler];
const QueryHandlers = [
GetReviewsByTargetHandler,
GetAverageRatingHandler,
GetReviewsByUserHandler,
];
@Module({
imports: [CqrsModule],
controllers: [ReviewsController],
providers: [
{ provide: REVIEW_REPOSITORY, useClass: PrismaReviewRepository },
...CommandHandlers,
...QueryHandlers,
],
exports: [REVIEW_REPOSITORY],
})
export class ReviewsModule {}