import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { CreateReviewDto } from '../dto/create-review.dto'; import { UpdateReviewDto } from '../dto/update-review.dto'; import { FindRestuarantReviewsDto, 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'; import { OrderItem } from '../../orders/entities/order-item.entity'; import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; import { ReviewStatus } from '../enums/review-status.enum'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { ReviewCreatedEvent } from '../events/review.events'; @Injectable() export class ReviewService { constructor( private readonly reviewRepository: ReviewRepository, private readonly foodRepository: FoodRepository, private readonly userRepository: UserRepository, private readonly em: EntityManager, private readonly eventEmitter: EventEmitter2, ) { } 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); } // Find order and verify it belongs to the user, populate restaurant to get restaurantId const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } }, { populate: ['restaurant'] }); if (!order) { throw new NotFoundException('Order not found or does not belong to the current user'); } // Verify the food is in the order const orderItem = await this.em.findOne(OrderItem, { order: { id: orderId }, food: { id: foodId }, }); if (!orderItem) { throw new BadRequestException('Food is not in the specified order'); } // 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, status: ReviewStatus.PENDING, }; const review = this.reviewRepository.create(data); if (!review) { throw new BadRequestException('Failed to create review entity'); } await this.em.persistAndFlush(review); // Update food rating average await this.updateFoodRating(foodId); // Emit ReviewCreatedEvent this.eventEmitter.emit( ReviewCreatedEvent.name, new ReviewCreatedEvent( review.id, order.restaurant.id, userId, foodId, food.title || 'Unknown Food', orderId, order.orderNumber || '', rating, ), ); return review; } async findAll(dto: FindReviewsDto) { return this.reviewRepository.findAllPaginated(dto); } async findRestuarantAllReviews(dto: FindRestuarantReviewsDto) { const { restuarantSlug, ...restDto } = dto; const restaurant = await this.em.findOne(Restaurant, { slug: restuarantSlug }); if (!restaurant) { throw new NotFoundException('RestaurantMessage.NOT_FOUND'); } return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id }); } async findById(id: string, restaurantId: string): Promise { const review = await this.reviewRepository.findOne( { id, order: { restaurant: { id: restaurantId } } }, { 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 status if (!isAdmin && dto.status !== undefined) { throw new BadRequestException('Only admins can change review 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 changeStatus(reviewId: string, status: ReviewStatus, restaurantId: string): Promise { const review = await this.reviewRepository.findOne({ id: reviewId, order: { restaurant: { id: restaurantId } } }); if (!review) { throw new NotFoundException(ReviewMessage.NOT_FOUND); } review.status = status; await this.em.persistAndFlush(review); 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 }, status: ReviewStatus.APPROVED, deletedAt: null }, { fields: ['rating'] }, ); if (reviews.length === 0) { const food = await this.foodRepository.findOne({ id: foodId }); if (food) { food.score = 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.score = parseFloat(averageRating.toFixed(2)); await this.em.persistAndFlush(food); } } }