This commit is contained in:
2025-12-07 13:00:37 +03:30
parent 1dc1eaad9a
commit a945138543
13 changed files with 360 additions and 316 deletions
@@ -0,0 +1,115 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
import { ReviewService } from '../providers/review.service';
import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-review.dto';
import { FindReviewsDto } from '../dto/find-reviews.dto';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiCreatedResponse,
ApiOkResponse,
ApiQuery,
ApiBody,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { UserId } from 'src/common/decorators/user-id.decorator';
import { RestId } from 'src/common/decorators/rest-id.decorator';
@ApiTags('review')
@Controller()
export class ReviewController {
constructor(private readonly reviewService: ReviewService) {}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Post('public/reviews')
@ApiOperation({ summary: 'Create a new review and rating' })
@ApiCreatedResponse({ description: 'The review has been successfully created.' })
@ApiBody({ type: CreateReviewDto })
create(@Body() createReviewDto: CreateReviewDto, @UserId() userId: string) {
return this.reviewService.create(userId, createReviewDto);
}
@Get('public/reviews/:foodId')
@ApiOperation({ summary: 'Get all reviews (public - only approved)' })
@ApiOkResponse({ description: 'List of approved reviews' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiParam({ name: 'foodId', required: true, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
findAll(@Query() dto: FindReviewsDto, @Param('foodId') foodId: string) {
// Only show approved reviews for public endpoint
return this.reviewService.findAll({ ...dto, isApproved: true, foodId });
}
// @Get('public/reviews/:id')
// @ApiOperation({ summary: 'Get a review by id' })
// @ApiParam({ name: 'id', required: true })
// @ApiOkResponse({ description: 'The review' })
// @ApiNotFoundResponse({ description: 'Review not found' })
// findById(@Param('id') id: string) {
// return this.reviewService.findById(id);
// }
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Patch('public/reviews/:reviewId')
@ApiOperation({ summary: 'Update a review (own reviews only)' })
@ApiParam({ name: 'reviewId', required: true })
@ApiBody({ type: UpdateReviewDto })
@ApiOkResponse({ description: 'The updated review' })
update(@Param('reviewId') reviewId: string, @Body() updateReviewDto: UpdateReviewDto, @UserId() userId: string) {
return this.reviewService.update(reviewId, userId, updateReviewDto, false);
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Delete('public/reviews/:id')
@ApiOperation({ summary: 'Delete a review (own reviews only)' })
@ApiParam({ name: 'id', required: true })
@ApiResponse({ status: 200, description: 'Review deleted' })
remove(@Param('id') id: string, @UserId() userId: string) {
return this.reviewService.remove(id, userId, false);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/reviews')
@ApiOperation({ summary: 'Get all reviews (admin - including unapproved)' })
@ApiOkResponse({ description: 'List of all reviews' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'foodId', required: false, type: String })
@ApiQuery({ name: 'userId', required: false, type: String })
@ApiQuery({ name: 'isApproved', required: false, type: Boolean })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
findAllAdmin(@Query() dto: FindReviewsDto, @RestId() restId: string) {
return this.reviewService.findAll({ ...dto, restId });
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Patch('admin/reviews/:id')
@ApiOperation({ summary: 'Update a review (admin - can change approval status)' })
@ApiParam({ name: 'id', required: true })
@ApiBody({ type: UpdateReviewDto })
updateAdmin(@Param('id') id: string, @Body() updateReviewDto: UpdateReviewDto, @UserId() userId: string) {
return this.reviewService.update(id, userId, updateReviewDto, true);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Delete('admin/reviews/:id')
@ApiOperation({ summary: 'Delete a review (admin)' })
@ApiParam({ name: 'id', required: true })
@ApiResponse({ status: 200, description: 'Review deleted' })
removeAdmin(@Param('id') id: string, @UserId() userId: string) {
return this.reviewService.remove(id, userId, true);
}
}
@@ -0,0 +1,49 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateReviewDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ description: 'Food ID' })
foodId: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ description: 'Order ID' })
orderId: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(5)
@Type(() => Number)
@ApiProperty({ description: 'Rating from 1 to 5', example: 5, minimum: 1, maximum: 5 })
rating: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'Comment text', example: 'Very delicious food!' })
comment?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
description: 'Positive points about the food',
example: ['Great taste', 'Fast delivery'],
type: [String]
})
positivePoints?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
description: 'Negative points about the food',
example: ['Too spicy', 'Small portion'],
type: [String]
})
negativePoints?: string[];
}
@@ -0,0 +1,49 @@
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class FindReviewsDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ description: 'Page number', example: 1 })
page?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ description: 'Items per page', example: 10 })
limit?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'Food ID to filter comments' })
foodId?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'Restaurant ID to filter comments' })
restId?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'User ID to filter comments' })
userId?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ description: 'Filter by approval status' })
@Type(() => Boolean)
isApproved?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'Order by field', example: 'createdAt' })
orderBy?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'Order direction', enum: ['asc', 'desc'], example: 'desc' })
order?: 'asc' | 'desc';
}
@@ -0,0 +1,43 @@
import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min, IsArray } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class UpdateReviewDto {
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
@Type(() => Number)
@ApiPropertyOptional({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
rating?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ description: 'Comment text' })
comment?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
description: 'Positive points about the food',
type: [String]
})
positivePoints?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
description: 'Negative points about the food',
type: [String]
})
negativePoints?: string[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ description: 'Approval status (admin only)' })
@Type(() => Boolean)
isApproved?: boolean;
}
@@ -0,0 +1,34 @@
import { Entity, ManyToOne, Property, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Food } from '../../foods/entities/food.entity';
import { User } from '../../users/entities/user.entity';
import { Order } from 'src/modules/orders/entities/order.entity';
@Entity({ tableName: 'food_comments' })
@Unique({ properties: ['order', 'food', 'user'] })
export class Review extends BaseEntity {
@ManyToOne(() => Order)
order: Order;
@ManyToOne(() => Food)
food: Food;
@ManyToOne(() => User)
user: User;
@Property({ type: 'text', nullable: true })
comment?: string;
@Property({ type: 'int', nullable: false })
rating: number = 0;
@Property({ type: 'json', nullable: true })
positivePoints?: string[];
@Property({ type: 'json', nullable: true })
negativePoints?: string[];
@Property({ type: 'boolean', default: false })
isApproved: boolean = false;
}
@@ -0,0 +1,159 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-review.dto';
import { FindReviewsDto } from '../dto/find-reviews.dto';
import { ReviewRepository } from '../repositories/review.repository';
import { FoodRepository } from '../../foods/repositories/food.repository';
import { UserRepository } from '../../users/repositories/user.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { Review } from '../entities/review.entity';
import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
import { Order } from '../../orders/entities/order.entity';
@Injectable()
export class ReviewService {
constructor(
private readonly reviewRepository: ReviewRepository,
private readonly foodRepository: FoodRepository,
private readonly userRepository: UserRepository,
private readonly em: EntityManager,
) {}
async create(userId: string, createReviewDto: CreateReviewDto): Promise<Review> {
const { foodId, orderId, rating, comment, positivePoints, negativePoints } = createReviewDto;
const food = await this.foodRepository.findOne({ id: foodId });
if (!food) {
throw new NotFoundException(FoodMessage.NOT_FOUND);
}
const user = await this.userRepository.findOne({ id: userId });
if (!user) {
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
}
const order = await this.em.findOne(Order, { id: orderId });
if (!order) {
throw new NotFoundException('Order not found');
}
// Check if user already commented on this food for this order
const existingReview = await this.reviewRepository.findOne({
order: { id: orderId },
food: { id: foodId },
user: { id: userId },
});
if (existingReview) {
throw new BadRequestException(ReviewMessage.ALREADY_COMMENTED);
}
const data: RequiredEntityData<Review> = {
order,
food,
user,
rating,
comment: comment || undefined,
positivePoints: positivePoints || undefined,
negativePoints: negativePoints || undefined,
isApproved: false,
};
const review = this.reviewRepository.create(data);
if (!review) {
throw new Error('Failed to create review entity');
}
await this.em.persistAndFlush(review);
// Update food rating average
await this.updateFoodRating(foodId);
return review;
}
async findAll(dto: FindReviewsDto) {
return this.reviewRepository.findAllPaginated(dto);
}
async findById(id: string): Promise<Review> {
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
return review;
}
async update(id: string, userId: string, dto: UpdateReviewDto, isAdmin: boolean = false): Promise<Review> {
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
// Only allow user to update their own reviews (unless admin)
if (!isAdmin && review.user.id !== userId) {
throw new BadRequestException(ReviewMessage.CAN_ONLY_UPDATE_OWN);
}
// Users can only update rating and comment, not isApproved
if (!isAdmin && dto.isApproved !== undefined) {
throw new BadRequestException('Only admins can change approval status');
}
const oldRating = review.rating;
this.em.assign(review, dto);
await this.em.persistAndFlush(review);
// Update food rating average if rating changed
if (dto.rating !== undefined && dto.rating !== oldRating) {
await this.updateFoodRating(review.food.id);
}
return review;
}
async remove(id: string, userId: string, isAdmin: boolean = false) {
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
// Only allow user to delete their own reviews (unless admin)
if (!isAdmin && review.user.id !== userId) {
throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
}
const foodId = review.food.id;
review.deletedAt = new Date();
await this.em.persistAndFlush(review);
// Update food rating average
await this.updateFoodRating(foodId);
}
private async updateFoodRating(foodId: string): Promise<void> {
const reviews = await this.reviewRepository.find(
{ food: { id: foodId }, isApproved: true, deletedAt: null },
{ fields: ['rating'] },
);
if (reviews.length === 0) {
const food = await this.foodRepository.findOne({ id: foodId });
if (food) {
food.rate = 0;
await this.em.persistAndFlush(food);
}
return;
}
const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
const food = await this.foodRepository.findOne({ id: foodId });
if (food) {
food.rate = parseFloat(averageRating.toFixed(2));
await this.em.persistAndFlush(food);
}
}
}
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { Review } from '../entities/review.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
type FindReviewsOpts = {
page?: number;
limit?: number;
foodId?: string;
userId?: string;
isApproved?: boolean;
orderBy?: string;
order?: 'asc' | 'desc';
};
@Injectable()
export class ReviewRepository extends EntityRepository<Review> {
constructor(readonly em: EntityManager) {
super(em, Review);
}
/**
* Find reviews with pagination and optional filters.
* Supports: foodId, userId, isApproved, ordering.
*/
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
const { page = 1, limit = 10, foodId, userId, isApproved, orderBy = 'createdAt', order = 'desc' } = opts;
const offset = (page - 1) * limit;
const where: FilterQuery<Review> = {};
if (foodId) {
where.food = { id: foodId };
}
if (userId) {
where.user = { id: userId };
}
if (typeof isApproved === 'boolean') {
where.isApproved = isApproved;
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['food', 'user', 'order'],
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ReviewService } from './providers/review.service';
import { ReviewController } from './controllers/review.controller';
import { ReviewRepository } from './repositories/review.repository';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Review } from './entities/review.entity';
import { FoodModule } from '../foods/food.module';
import { UserModule } from '../users/user.module';
import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
MikroOrmModule.forFeature([Review]),
FoodModule,
UserModule,
AuthModule,
JwtModule,
],
controllers: [ReviewController],
providers: [ReviewService, ReviewRepository],
exports: [ReviewRepository],
})
export class ReviewModule {}