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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user