From a945138543e46bc06780d156e9a61aa838a8a366 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 7 Dec 2025 13:00:37 +0330 Subject: [PATCH] review --- src/app.module.ts | 4 +- src/common/enums/message.enum.ts | 2 +- .../controllers/food-comment.controller.ts | 119 ------------- .../food-comments/food-comments.module.ts | 25 --- .../providers/food-comment.service.ts | 156 ----------------- .../review/controllers/review.controller.ts | 115 +++++++++++++ .../dto/create-review.dto.ts} | 24 ++- .../dto/find-reviews.dto.ts} | 2 +- .../dto/update-review.dto.ts} | 22 ++- .../entities/review.entity.ts} | 9 +- .../review/providers/review.service.ts | 159 ++++++++++++++++++ .../repositories/review.repository.ts} | 14 +- src/modules/review/review.module.ts | 25 +++ 13 files changed, 360 insertions(+), 316 deletions(-) delete mode 100644 src/modules/food-comments/controllers/food-comment.controller.ts delete mode 100644 src/modules/food-comments/food-comments.module.ts delete mode 100644 src/modules/food-comments/providers/food-comment.service.ts create mode 100644 src/modules/review/controllers/review.controller.ts rename src/modules/{food-comments/dto/create-food-comment.dto.ts => review/dto/create-review.dto.ts} (50%) rename src/modules/{food-comments/dto/find-food-comments.dto.ts => review/dto/find-reviews.dto.ts} (97%) rename src/modules/{food-comments/dto/update-food-comment.dto.ts => review/dto/update-review.dto.ts} (51%) rename src/modules/{food-comments/entities/food-comment.entity.ts => review/entities/review.entity.ts} (79%) create mode 100644 src/modules/review/providers/review.service.ts rename src/modules/{food-comments/repositories/food-comment.repository.ts => review/repositories/review.repository.ts} (75%) create mode 100644 src/modules/review/review.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 2e5b04e..5676a11 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,7 +23,7 @@ import { PaymentsModule } from './modules/payments/payments.module'; import { DeliveryModule } from './modules/delivery/delivery.module'; import { OrdersModule } from './modules/orders/orders.module'; import { CouponModule } from './modules/coupons/coupon.module'; -import { FoodCommentsModule } from './modules/food-comments/food-comments.module'; +import { ReviewModule } from './modules/review/review.module'; @Module({ imports: [ @@ -60,7 +60,7 @@ import { FoodCommentsModule } from './modules/food-comments/food-comments.module DeliveryModule, OrdersModule, CouponModule, - FoodCommentsModule, + ReviewModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index d5fe98b..c9dd793 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -638,7 +638,7 @@ export const enum CouponMessage { COUPON_INVALID = 'کوپن نامعتبر است', } -export const enum FoodCommentMessage { +export const enum ReviewMessage { NOT_FOUND = 'نظر یافت نشد', ALREADY_COMMENTED = 'شما قبلاً برای این غذا نظر داده‌اید', CAN_ONLY_UPDATE_OWN = 'شما فقط می‌توانید نظرات خود را ویرایش کنید', diff --git a/src/modules/food-comments/controllers/food-comment.controller.ts b/src/modules/food-comments/controllers/food-comment.controller.ts deleted file mode 100644 index 8eb23bd..0000000 --- a/src/modules/food-comments/controllers/food-comment.controller.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common'; -import { FoodCommentService } from '../providers/food-comment.service'; -import { CreateFoodCommentDto } from '../dto/create-food-comment.dto'; -import { UpdateFoodCommentDto } from '../dto/update-food-comment.dto'; -import { FindFoodCommentsDto } from '../dto/find-food-comments.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('food-comments') -@Controller() -export class FoodCommentController { - constructor(private readonly foodCommentService: FoodCommentService) {} - - @UseGuards(AuthGuard) - @ApiBearerAuth() - @Post('public/food-comments') - @ApiOperation({ summary: 'Create a new food comment and rating' }) - @ApiCreatedResponse({ description: 'The comment has been successfully created.' }) - @ApiBody({ type: CreateFoodCommentDto }) - create(@Body() createFoodCommentDto: CreateFoodCommentDto, @UserId() userId: string) { - return this.foodCommentService.create(userId, createFoodCommentDto); - } - - @Get('public/food-comments/:foodId') - @ApiOperation({ summary: 'Get all food comments (public - only approved)' }) - @ApiOkResponse({ description: 'List of approved food comments' }) - @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: FindFoodCommentsDto, @Param('foodId') foodId: string) { - // Only show approved comments for public endpoint - return this.foodCommentService.findAll({ ...dto, isApproved: true, foodId }); - } - - // @Get('public/food-comments/:id') - // @ApiOperation({ summary: 'Get a food comment by id' }) - // @ApiParam({ name: 'id', required: true }) - // @ApiOkResponse({ description: 'The food comment' }) - // @ApiNotFoundResponse({ description: 'Comment not found' }) - // findById(@Param('id') id: string) { - // return this.foodCommentService.findById(id); - // } - - @UseGuards(AuthGuard) - @ApiBearerAuth() - @Patch('public/food-comments/:foodCommentId') - @ApiOperation({ summary: 'Update a food comment (own comments only)' }) - @ApiParam({ name: 'foodCommentId', required: true }) - @ApiBody({ type: UpdateFoodCommentDto }) - @ApiOkResponse({ description: 'The updated comment' }) - update( - @Param('foodCommentId') foodCommentId: string, - @Body() updateFoodCommentDto: UpdateFoodCommentDto, - @UserId() userId: string, - ) { - return this.foodCommentService.update(foodCommentId, userId, updateFoodCommentDto, false); - } - - @UseGuards(AuthGuard) - @ApiBearerAuth() - @Delete('public/food-comments/:id') - @ApiOperation({ summary: 'Delete a food comment (own comments only)' }) - @ApiParam({ name: 'id', required: true }) - @ApiResponse({ status: 200, description: 'Comment deleted' }) - remove(@Param('id') id: string, @UserId() userId: string) { - return this.foodCommentService.remove(id, userId, false); - } - - @UseGuards(AdminAuthGuard) - @ApiBearerAuth() - @Get('admin/food-comments') - @ApiOperation({ summary: 'Get all food comments (admin - including unapproved)' }) - @ApiOkResponse({ description: 'List of all food comments' }) - @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: FindFoodCommentsDto, @RestId() restId: string) { - return this.foodCommentService.findAll({ ...dto, restId }); - } - - @UseGuards(AdminAuthGuard) - @ApiBearerAuth() - @Patch('admin/food-comments/:id') - @ApiOperation({ summary: 'Update a food comment (admin - can change approval status)' }) - @ApiParam({ name: 'id', required: true }) - @ApiBody({ type: UpdateFoodCommentDto }) - updateAdmin(@Param('id') id: string, @Body() updateFoodCommentDto: UpdateFoodCommentDto, @UserId() userId: string) { - return this.foodCommentService.update(id, userId, updateFoodCommentDto, true); - } - - @UseGuards(AdminAuthGuard) - @ApiBearerAuth() - @Delete('admin/food-comments/:id') - @ApiOperation({ summary: 'Delete a food comment (admin)' }) - @ApiParam({ name: 'id', required: true }) - @ApiResponse({ status: 200, description: 'Comment deleted' }) - removeAdmin(@Param('id') id: string, @UserId() userId: string) { - return this.foodCommentService.remove(id, userId, true); - } -} diff --git a/src/modules/food-comments/food-comments.module.ts b/src/modules/food-comments/food-comments.module.ts deleted file mode 100644 index d08c90c..0000000 --- a/src/modules/food-comments/food-comments.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FoodCommentService } from './providers/food-comment.service'; -import { FoodCommentController } from './controllers/food-comment.controller'; -import { FoodCommentRepository } from './repositories/food-comment.repository'; -import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { FoodComment } from './entities/food-comment.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([FoodComment]), - FoodModule, - UserModule, - AuthModule, - JwtModule, - ], - controllers: [FoodCommentController], - providers: [FoodCommentService, FoodCommentRepository], - exports: [FoodCommentRepository], -}) -export class FoodCommentsModule {} - diff --git a/src/modules/food-comments/providers/food-comment.service.ts b/src/modules/food-comments/providers/food-comment.service.ts deleted file mode 100644 index 9440ae6..0000000 --- a/src/modules/food-comments/providers/food-comment.service.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { CreateFoodCommentDto } from '../dto/create-food-comment.dto'; -import { UpdateFoodCommentDto } from '../dto/update-food-comment.dto'; -import { FindFoodCommentsDto } from '../dto/find-food-comments.dto'; -import { FoodCommentRepository } from '../repositories/food-comment.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 { FoodComment } from '../entities/food-comment.entity'; -import { FoodMessage, UserMessage, FoodCommentMessage } from 'src/common/enums/message.enum'; -import { Order } from '../../orders/entities/order.entity'; - -@Injectable() -export class FoodCommentService { - constructor( - private readonly foodCommentRepository: FoodCommentRepository, - private readonly foodRepository: FoodRepository, - private readonly userRepository: UserRepository, - private readonly em: EntityManager, - ) {} - - async create(userId: string, createFoodCommentDto: CreateFoodCommentDto): Promise { - const { foodId, orderId, rating, comment } = createFoodCommentDto; - - 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 existingComment = await this.foodCommentRepository.findOne({ - order: { id: orderId }, - food: { id: foodId }, - user: { id: userId }, - }); - - if (existingComment) { - throw new BadRequestException(FoodCommentMessage.ALREADY_COMMENTED); - } - - const data: RequiredEntityData = { - order, - food, - user, - rating, - comment: comment || undefined, - isApproved: false, - }; - - const foodComment = this.foodCommentRepository.create(data); - if (!foodComment) { - throw new Error('Failed to create food comment entity'); - } - - await this.em.persistAndFlush(foodComment); - - // Update food rating average - await this.updateFoodRating(foodId); - - return foodComment; - } - - async findAll(dto: FindFoodCommentsDto) { - return this.foodCommentRepository.findAllPaginated(dto); - } - - async findById(id: string): Promise { - const comment = await this.foodCommentRepository.findOne({ id }, { populate: ['food', 'user', 'order'] }); - if (!comment) { - throw new NotFoundException(FoodCommentMessage.NOT_FOUND); - } - return comment; - } - - async update(id: string, userId: string, dto: UpdateFoodCommentDto, isAdmin: boolean = false): Promise { - const comment = await this.foodCommentRepository.findOne({ id }, { populate: ['food', 'user', 'order'] }); - if (!comment) { - throw new NotFoundException(FoodCommentMessage.NOT_FOUND); - } - - // Only allow user to update their own comments (unless admin) - if (!isAdmin && comment.user.id !== userId) { - throw new BadRequestException(FoodCommentMessage.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 = comment.rating; - this.em.assign(comment, dto); - await this.em.persistAndFlush(comment); - - // Update food rating average if rating changed - if (dto.rating !== undefined && dto.rating !== oldRating) { - await this.updateFoodRating(comment.food.id); - } - - return comment; - } - - async remove(id: string, userId: string, isAdmin: boolean = false) { - const comment = await this.foodCommentRepository.findOne({ id }, { populate: ['food', 'user', 'order'] }); - if (!comment) { - throw new NotFoundException(FoodCommentMessage.NOT_FOUND); - } - - // Only allow user to delete their own comments (unless admin) - if (!isAdmin && comment.user.id !== userId) { - throw new BadRequestException(FoodCommentMessage.CAN_ONLY_DELETE_OWN); - } - - const foodId = comment.food.id; - comment.deletedAt = new Date(); - await this.em.persistAndFlush(comment); - - // Update food rating average - await this.updateFoodRating(foodId); - } - - private async updateFoodRating(foodId: string): Promise { - const comments = await this.foodCommentRepository.find( - { food: { id: foodId }, isApproved: true, deletedAt: null }, - { fields: ['rating'] }, - ); - - if (comments.length === 0) { - const food = await this.foodRepository.findOne({ id: foodId }); - if (food) { - food.rate = 0; - await this.em.persistAndFlush(food); - } - return; - } - - const averageRating = comments.reduce((sum, comment) => sum + comment.rating, 0) / comments.length; - - const food = await this.foodRepository.findOne({ id: foodId }); - if (food) { - food.rate = parseFloat(averageRating.toFixed(2)); - await this.em.persistAndFlush(food); - } - } -} diff --git a/src/modules/review/controllers/review.controller.ts b/src/modules/review/controllers/review.controller.ts new file mode 100644 index 0000000..b2a1186 --- /dev/null +++ b/src/modules/review/controllers/review.controller.ts @@ -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); + } +} diff --git a/src/modules/food-comments/dto/create-food-comment.dto.ts b/src/modules/review/dto/create-review.dto.ts similarity index 50% rename from src/modules/food-comments/dto/create-food-comment.dto.ts rename to src/modules/review/dto/create-review.dto.ts index a3de025..08aac49 100644 --- a/src/modules/food-comments/dto/create-food-comment.dto.ts +++ b/src/modules/review/dto/create-review.dto.ts @@ -1,8 +1,8 @@ -import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, IsArray } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -export class CreateFoodCommentDto { +export class CreateReviewDto { @IsNotEmpty() @IsString() @ApiProperty({ description: 'Food ID' }) @@ -25,5 +25,25 @@ export class CreateFoodCommentDto { @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[]; } diff --git a/src/modules/food-comments/dto/find-food-comments.dto.ts b/src/modules/review/dto/find-reviews.dto.ts similarity index 97% rename from src/modules/food-comments/dto/find-food-comments.dto.ts rename to src/modules/review/dto/find-reviews.dto.ts index 93e9f60..4254097 100644 --- a/src/modules/food-comments/dto/find-food-comments.dto.ts +++ b/src/modules/review/dto/find-reviews.dto.ts @@ -2,7 +2,7 @@ import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -export class FindFoodCommentsDto { +export class FindReviewsDto { @IsOptional() @IsNumber() @Type(() => Number) diff --git a/src/modules/food-comments/dto/update-food-comment.dto.ts b/src/modules/review/dto/update-review.dto.ts similarity index 51% rename from src/modules/food-comments/dto/update-food-comment.dto.ts rename to src/modules/review/dto/update-review.dto.ts index 6c3f502..efa8008 100644 --- a/src/modules/food-comments/dto/update-food-comment.dto.ts +++ b/src/modules/review/dto/update-review.dto.ts @@ -1,8 +1,8 @@ -import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min, IsArray } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -export class UpdateFoodCommentDto { +export class UpdateReviewDto { @IsOptional() @IsNumber() @Min(1) @@ -16,6 +16,24 @@ export class UpdateFoodCommentDto { @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)' }) diff --git a/src/modules/food-comments/entities/food-comment.entity.ts b/src/modules/review/entities/review.entity.ts similarity index 79% rename from src/modules/food-comments/entities/food-comment.entity.ts rename to src/modules/review/entities/review.entity.ts index ee9186b..e004476 100644 --- a/src/modules/food-comments/entities/food-comment.entity.ts +++ b/src/modules/review/entities/review.entity.ts @@ -6,7 +6,7 @@ import { Order } from 'src/modules/orders/entities/order.entity'; @Entity({ tableName: 'food_comments' }) @Unique({ properties: ['order', 'food', 'user'] }) -export class FoodComment extends BaseEntity { +export class Review extends BaseEntity { @ManyToOne(() => Order) order: Order; @@ -22,6 +22,13 @@ export class FoodComment extends BaseEntity { @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; } + diff --git a/src/modules/review/providers/review.service.ts b/src/modules/review/providers/review.service.ts new file mode 100644 index 0000000..45d82dd --- /dev/null +++ b/src/modules/review/providers/review.service.ts @@ -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 { + 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 = { + 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 { + 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 { + 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 { + 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); + } + } +} + diff --git a/src/modules/food-comments/repositories/food-comment.repository.ts b/src/modules/review/repositories/review.repository.ts similarity index 75% rename from src/modules/food-comments/repositories/food-comment.repository.ts rename to src/modules/review/repositories/review.repository.ts index 1fa8942..2f247ae 100644 --- a/src/modules/food-comments/repositories/food-comment.repository.ts +++ b/src/modules/review/repositories/review.repository.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; import { FilterQuery } from '@mikro-orm/core'; -import { FoodComment } from '../entities/food-comment.entity'; +import { Review } from '../entities/review.entity'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; -type FindFoodCommentsOpts = { +type FindReviewsOpts = { page?: number; limit?: number; foodId?: string; @@ -15,21 +15,21 @@ type FindFoodCommentsOpts = { }; @Injectable() -export class FoodCommentRepository extends EntityRepository { +export class ReviewRepository extends EntityRepository { constructor(readonly em: EntityManager) { - super(em, FoodComment); + super(em, Review); } /** - * Find food comments with pagination and optional filters. + * Find reviews with pagination and optional filters. * Supports: foodId, userId, isApproved, ordering. */ - async findAllPaginated(opts: FindFoodCommentsOpts = {}): Promise> { + async findAllPaginated(opts: FindReviewsOpts = {}): Promise> { const { page = 1, limit = 10, foodId, userId, isApproved, orderBy = 'createdAt', order = 'desc' } = opts; const offset = (page - 1) * limit; - const where: FilterQuery = {}; + const where: FilterQuery = {}; if (foodId) { where.food = { id: foodId }; diff --git a/src/modules/review/review.module.ts b/src/modules/review/review.module.ts new file mode 100644 index 0000000..de16608 --- /dev/null +++ b/src/modules/review/review.module.ts @@ -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 {} +