From f2ae0fe020982845fc94dbf3b8718e5bc9d82e2a Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 7 Dec 2025 00:03:32 +0330 Subject: [PATCH] comments --- src/app.module.ts | 2 + src/common/enums/message.enum.ts | 12 ++ .../controllers/food-comment.controller.ts | 117 ++++++++++++++ .../dto/create-food-comment.dto.ts | 24 +++ .../dto/find-food-comments.dto.ts | 44 ++++++ .../dto/update-food-comment.dto.ts | 25 +++ .../entities/food-comment.entity.ts | 22 +++ .../food-comments/food-comments.module.ts | 25 +++ .../providers/food-comment.service.ts | 149 ++++++++++++++++++ .../repositories/food-comment.repository.ts | 66 ++++++++ 10 files changed, 486 insertions(+) create mode 100644 src/modules/food-comments/controllers/food-comment.controller.ts create mode 100644 src/modules/food-comments/dto/create-food-comment.dto.ts create mode 100644 src/modules/food-comments/dto/find-food-comments.dto.ts create mode 100644 src/modules/food-comments/dto/update-food-comment.dto.ts create mode 100644 src/modules/food-comments/entities/food-comment.entity.ts create mode 100644 src/modules/food-comments/food-comments.module.ts create mode 100644 src/modules/food-comments/providers/food-comment.service.ts create mode 100644 src/modules/food-comments/repositories/food-comment.repository.ts diff --git a/src/app.module.ts b/src/app.module.ts index 25d2802..2e5b04e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +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'; @Module({ imports: [ @@ -59,6 +60,7 @@ import { CouponModule } from './modules/coupons/coupon.module'; DeliveryModule, OrdersModule, CouponModule, + FoodCommentsModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 96c8bb2..d5fe98b 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -637,3 +637,15 @@ export const enum CouponMessage { COUPON_VALID = 'کوپن معتبر است', COUPON_INVALID = 'کوپن نامعتبر است', } + +export const enum FoodCommentMessage { + NOT_FOUND = 'نظر یافت نشد', + ALREADY_COMMENTED = 'شما قبلاً برای این غذا نظر داده‌اید', + CAN_ONLY_UPDATE_OWN = 'شما فقط می‌توانید نظرات خود را ویرایش کنید', + CAN_ONLY_DELETE_OWN = 'شما فقط می‌توانید نظرات خود را حذف کنید', + RATING_REQUIRED = 'امتیاز الزامی است', + RATING_INVALID = 'امتیاز باید بین ۱ تا ۵ باشد', + COMMENT_CREATED = 'نظر با موفقیت ثبت شد', + COMMENT_UPDATED = 'نظر با موفقیت به‌روزرسانی شد', + COMMENT_DELETED = 'نظر با موفقیت حذف شد', +} diff --git a/src/modules/food-comments/controllers/food-comment.controller.ts b/src/modules/food-comments/controllers/food-comment.controller.ts new file mode 100644 index 0000000..44efb5d --- /dev/null +++ b/src/modules/food-comments/controllers/food-comment.controller.ts @@ -0,0 +1,117 @@ +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, + ApiNotFoundResponse, + 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'; + +@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') + @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 }) + @ApiQuery({ name: 'foodId', required: false, type: String }) + @ApiQuery({ name: 'orderBy', required: false, type: String }) + @ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] }) + findAll(@Query() dto: FindFoodCommentsDto) { + // Only show approved comments for public endpoint + return this.foodCommentService.findAll({ ...dto, isApproved: true }); + } + + @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/:id') + @ApiOperation({ summary: 'Update a food comment (own comments only)' }) + @ApiParam({ name: 'id', required: true }) + @ApiBody({ type: UpdateFoodCommentDto }) + @ApiOkResponse({ description: 'The updated comment' }) + update(@Param('id') id: string, @Body() updateFoodCommentDto: UpdateFoodCommentDto, @UserId() userId: string) { + return this.foodCommentService.update(id, 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) { + return this.foodCommentService.findAll(dto); + } + + @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 }) + @ApiOkResponse({ description: 'The updated comment' }) + 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/dto/create-food-comment.dto.ts b/src/modules/food-comments/dto/create-food-comment.dto.ts new file mode 100644 index 0000000..253d9fa --- /dev/null +++ b/src/modules/food-comments/dto/create-food-comment.dto.ts @@ -0,0 +1,24 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class CreateFoodCommentDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ description: 'Food ID' }) + foodId: 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; +} + diff --git a/src/modules/food-comments/dto/find-food-comments.dto.ts b/src/modules/food-comments/dto/find-food-comments.dto.ts new file mode 100644 index 0000000..387f08c --- /dev/null +++ b/src/modules/food-comments/dto/find-food-comments.dto.ts @@ -0,0 +1,44 @@ +import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class FindFoodCommentsDto { + @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: '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'; +} + diff --git a/src/modules/food-comments/dto/update-food-comment.dto.ts b/src/modules/food-comments/dto/update-food-comment.dto.ts new file mode 100644 index 0000000..6c3f502 --- /dev/null +++ b/src/modules/food-comments/dto/update-food-comment.dto.ts @@ -0,0 +1,25 @@ +import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class UpdateFoodCommentDto { + @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() + @IsBoolean() + @ApiPropertyOptional({ description: 'Approval status (admin only)' }) + @Type(() => Boolean) + isApproved?: boolean; +} + diff --git a/src/modules/food-comments/entities/food-comment.entity.ts b/src/modules/food-comments/entities/food-comment.entity.ts new file mode 100644 index 0000000..e108cfc --- /dev/null +++ b/src/modules/food-comments/entities/food-comment.entity.ts @@ -0,0 +1,22 @@ +import { Entity, ManyToOne, Property } 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'; + +@Entity({ tableName: 'food_comments' }) +export class FoodComment extends BaseEntity { + @ManyToOne(() => Food) + food!: Food; + + @ManyToOne(() => User) + user!: User; + + @Property({ type: 'text', nullable: true }) + comment?: string; + + @Property({ type: 'int', nullable: false }) + rating!: number; // 1-5 + + @Property({ type: 'boolean', default: false }) + isApproved: boolean = false; +} diff --git a/src/modules/food-comments/food-comments.module.ts b/src/modules/food-comments/food-comments.module.ts new file mode 100644 index 0000000..d08c90c --- /dev/null +++ b/src/modules/food-comments/food-comments.module.ts @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..e239ca2 --- /dev/null +++ b/src/modules/food-comments/providers/food-comment.service.ts @@ -0,0 +1,149 @@ +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'; + +@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, 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); + } + + // Check if user already commented on this food + const existingComment = await this.foodCommentRepository.findOne({ + food: { id: foodId }, + user: { id: userId }, + }); + + if (existingComment) { + throw new BadRequestException(FoodCommentMessage.ALREADY_COMMENTED); + } + + const data: RequiredEntityData = { + 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'] }); + 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'] }); + 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'] }); + 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/food-comments/repositories/food-comment.repository.ts b/src/modules/food-comments/repositories/food-comment.repository.ts new file mode 100644 index 0000000..fc5fcd7 --- /dev/null +++ b/src/modules/food-comments/repositories/food-comment.repository.ts @@ -0,0 +1,66 @@ +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 { PaginatedResult } from 'src/common/interfaces/pagination.interface'; + +type FindFoodCommentsOpts = { + page?: number; + limit?: number; + foodId?: string; + userId?: string; + isApproved?: boolean; + orderBy?: string; + order?: 'asc' | 'desc'; +}; + +@Injectable() +export class FoodCommentRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, FoodComment); + } + + /** + * Find food comments with pagination and optional filters. + * Supports: foodId, userId, isApproved, ordering. + */ + async findAllPaginated(opts: FindFoodCommentsOpts = {}): Promise> { + const { page = 1, limit = 10, foodId, userId, isApproved, orderBy = 'createdAt', order = 'desc' } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + 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'], + }); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } +} +