review
This commit is contained in:
+2
-2
@@ -23,7 +23,7 @@ import { PaymentsModule } from './modules/payments/payments.module';
|
|||||||
import { DeliveryModule } from './modules/delivery/delivery.module';
|
import { DeliveryModule } from './modules/delivery/delivery.module';
|
||||||
import { OrdersModule } from './modules/orders/orders.module';
|
import { OrdersModule } from './modules/orders/orders.module';
|
||||||
import { CouponModule } from './modules/coupons/coupon.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -60,7 +60,7 @@ import { FoodCommentsModule } from './modules/food-comments/food-comments.module
|
|||||||
DeliveryModule,
|
DeliveryModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
CouponModule,
|
CouponModule,
|
||||||
FoodCommentsModule,
|
ReviewModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ export const enum CouponMessage {
|
|||||||
COUPON_INVALID = 'کوپن نامعتبر است',
|
COUPON_INVALID = 'کوپن نامعتبر است',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum FoodCommentMessage {
|
export const enum ReviewMessage {
|
||||||
NOT_FOUND = 'نظر یافت نشد',
|
NOT_FOUND = 'نظر یافت نشد',
|
||||||
ALREADY_COMMENTED = 'شما قبلاً برای این غذا نظر دادهاید',
|
ALREADY_COMMENTED = 'شما قبلاً برای این غذا نظر دادهاید',
|
||||||
CAN_ONLY_UPDATE_OWN = 'شما فقط میتوانید نظرات خود را ویرایش کنید',
|
CAN_ONLY_UPDATE_OWN = 'شما فقط میتوانید نظرات خود را ویرایش کنید',
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
|
|
||||||
@@ -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<FoodComment> {
|
|
||||||
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<FoodComment> = {
|
|
||||||
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<FoodComment> {
|
|
||||||
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<FoodComment> {
|
|
||||||
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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-2
@@ -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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class CreateFoodCommentDto {
|
export class CreateReviewDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({ description: 'Food ID' })
|
@ApiProperty({ description: 'Food ID' })
|
||||||
@@ -25,5 +25,25 @@ export class CreateFoodCommentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@ApiPropertyOptional({ description: 'Comment text', example: 'Very delicious food!' })
|
@ApiPropertyOptional({ description: 'Comment text', example: 'Very delicious food!' })
|
||||||
comment?: string;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class FindFoodCommentsDto {
|
export class FindReviewsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
+20
-2
@@ -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 { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateFoodCommentDto {
|
export class UpdateReviewDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -16,6 +16,24 @@ export class UpdateFoodCommentDto {
|
|||||||
@ApiPropertyOptional({ description: 'Comment text' })
|
@ApiPropertyOptional({ description: 'Comment text' })
|
||||||
comment?: string;
|
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()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@ApiPropertyOptional({ description: 'Approval status (admin only)' })
|
@ApiPropertyOptional({ description: 'Approval status (admin only)' })
|
||||||
+8
-1
@@ -6,7 +6,7 @@ import { Order } from 'src/modules/orders/entities/order.entity';
|
|||||||
|
|
||||||
@Entity({ tableName: 'food_comments' })
|
@Entity({ tableName: 'food_comments' })
|
||||||
@Unique({ properties: ['order', 'food', 'user'] })
|
@Unique({ properties: ['order', 'food', 'user'] })
|
||||||
export class FoodComment extends BaseEntity {
|
export class Review extends BaseEntity {
|
||||||
@ManyToOne(() => Order)
|
@ManyToOne(() => Order)
|
||||||
order: Order;
|
order: Order;
|
||||||
|
|
||||||
@@ -22,6 +22,13 @@ export class FoodComment extends BaseEntity {
|
|||||||
@Property({ type: 'int', nullable: false })
|
@Property({ type: 'int', nullable: false })
|
||||||
rating: number = 0;
|
rating: number = 0;
|
||||||
|
|
||||||
|
@Property({ type: 'json', nullable: true })
|
||||||
|
positivePoints?: string[];
|
||||||
|
|
||||||
|
@Property({ type: 'json', nullable: true })
|
||||||
|
negativePoints?: string[];
|
||||||
|
|
||||||
@Property({ type: 'boolean', default: false })
|
@Property({ type: 'boolean', default: false })
|
||||||
isApproved: boolean = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
import { FilterQuery } from '@mikro-orm/core';
|
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';
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
|
||||||
type FindFoodCommentsOpts = {
|
type FindReviewsOpts = {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
foodId?: string;
|
foodId?: string;
|
||||||
@@ -15,21 +15,21 @@ type FindFoodCommentsOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FoodCommentRepository extends EntityRepository<FoodComment> {
|
export class ReviewRepository extends EntityRepository<Review> {
|
||||||
constructor(readonly em: EntityManager) {
|
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.
|
* Supports: foodId, userId, isApproved, ordering.
|
||||||
*/
|
*/
|
||||||
async findAllPaginated(opts: FindFoodCommentsOpts = {}): Promise<PaginatedResult<FoodComment>> {
|
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
|
||||||
const { page = 1, limit = 10, foodId, userId, isApproved, orderBy = 'createdAt', order = 'desc' } = opts;
|
const { page = 1, limit = 10, foodId, userId, isApproved, orderBy = 'createdAt', order = 'desc' } = opts;
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const where: FilterQuery<FoodComment> = {};
|
const where: FilterQuery<Review> = {};
|
||||||
|
|
||||||
if (foodId) {
|
if (foodId) {
|
||||||
where.food = { id: foodId };
|
where.food = { id: foodId };
|
||||||
@@ -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 {}
|
||||||
|
|
||||||
Reference in New Issue
Block a user