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

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