comments
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<FoodComment> {
|
||||
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<FoodComment> = {
|
||||
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'] });
|
||||
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'] });
|
||||
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<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,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<FoodComment> {
|
||||
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<PaginatedResult<FoodComment>> {
|
||||
const { page = 1, limit = 10, foodId, userId, isApproved, orderBy = 'createdAt', order = 'desc' } = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<FoodComment> = {};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user