review
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260716120000_addReviewsParentId extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table "reviews" add column "parent_id" char(26) null;`);
|
||||
this.addSql(`alter table "reviews" add constraint "reviews_parent_id_foreign" foreign key ("parent_id") references "reviews" ("id") on update cascade on delete set null;`);
|
||||
this.addSql(`create index "reviews_parent_id_index" on "reviews" ("parent_id");`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "reviews" drop constraint "reviews_parent_id_foreign";`);
|
||||
this.addSql(`drop index "reviews_parent_id_index";`);
|
||||
this.addSql(`alter table "reviews" drop column "parent_id";`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260716123000_nullableReviewUserForReplies extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table "reviews" alter column "user_id" drop not null;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "reviews" alter column "user_id" set not null;`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260717100000_addReviewsAdminId extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table "reviews" add column "admin_id" char(26) null;`);
|
||||
this.addSql(`alter table "reviews" add constraint "reviews_admin_id_foreign" foreign key ("admin_id") references "admins" ("id") on update cascade on delete set null;`);
|
||||
this.addSql(`create index "reviews_admin_id_index" on "reviews" ("admin_id");`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "reviews" drop constraint "reviews_admin_id_foreign";`);
|
||||
this.addSql(`drop index "reviews_admin_id_index";`);
|
||||
this.addSql(`alter table "reviews" drop column "admin_id";`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -660,6 +660,11 @@ export const enum ReviewMessage {
|
||||
COMMENT_CREATED = 'نظر با موفقیت ثبت شد',
|
||||
COMMENT_UPDATED = 'نظر با موفقیت بهروزرسانی شد',
|
||||
COMMENT_DELETED = 'نظر با موفقیت حذف شد',
|
||||
CANNOT_REPLY_TO_REPLY = 'امکان پاسخ به پاسخ وجود ندارد',
|
||||
REPLY_CREATED = 'پاسخ با موفقیت ثبت شد',
|
||||
REPLY_UPDATED = 'پاسخ با موفقیت بهروزرسانی شد',
|
||||
REPLY_DELETED = 'پاسخ با موفقیت حذف شد',
|
||||
REPLY_NOT_FOUND = 'پاسخی برای این نظر یافت نشد',
|
||||
}
|
||||
|
||||
export const enum IconMessage {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { UpdateReviewDto } from '../dto/update-review.dto';
|
||||
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
|
||||
import { ChangeStatusDto } from '../dto/change-status.dto';
|
||||
import { ReplyReviewDto } from '../dto/reply-review.dto';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
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 { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
@@ -114,7 +116,7 @@ export class ReviewController {
|
||||
@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: restId });
|
||||
return this.reviewService.findAll({ ...dto, topLevel: true, restId: restId });
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@@ -135,7 +137,7 @@ export class ReviewController {
|
||||
@ApiOperation({ summary: 'review detail' })
|
||||
@ApiParam({ name: 'id', required: true })
|
||||
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
|
||||
return this.reviewService.findById(reviewId, restaurantId);
|
||||
return this.reviewService.findWithReplies(reviewId, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@@ -153,6 +155,33 @@ export class ReviewController {
|
||||
return this.reviewService.changeStatus(reviewId, changeStatusDto.status, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@Post('admin/reviews/:id/reply')
|
||||
@ApiOperation({ summary: 'Create or update restaurant reply to a review' })
|
||||
@ApiParam({ name: 'id', required: true, description: 'Parent review ID' })
|
||||
@ApiBody({ type: ReplyReviewDto })
|
||||
@ApiOkResponse({ description: 'The created or updated reply' })
|
||||
reply(
|
||||
@Param('id') reviewId: string,
|
||||
@Body() replyReviewDto: ReplyReviewDto,
|
||||
@RestId() restaurantId: string,
|
||||
@AdminId() adminId: string,
|
||||
) {
|
||||
return this.reviewService.reply(reviewId, restaurantId, adminId, replyReviewDto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@Delete('admin/reviews/:id/reply')
|
||||
@ApiOperation({ summary: 'Delete restaurant reply to a review' })
|
||||
@ApiParam({ name: 'id', required: true, description: 'Parent review ID' })
|
||||
removeReply(@Param('id') reviewId: string, @RestId() restaurantId: string) {
|
||||
return this.reviewService.removeReply(reviewId, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReplyReviewDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@ApiProperty({ description: 'Restaurant reply comment', example: 'از بازخورد شما سپاسگزاریم' })
|
||||
comment: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Entity, Index, ManyToOne, Property, Unique, Enum } from '@mikro-orm/cor
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Order } from 'src/modules/orders/entities/order.entity';
|
||||
import { Admin } from '../../admin/entities/admin.entity';
|
||||
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ReviewStatus } from '../enums/review-status.enum';
|
||||
@Unique({ properties: ['food', 'user'] })
|
||||
@Index({ properties: ['food', 'status'] })
|
||||
@Index({ properties: ['user'] })
|
||||
@Index({ properties: ['parent'] })
|
||||
@Index({ properties: ['admin'] })
|
||||
export class Review extends BaseEntity {
|
||||
@Property({ type: 'boolean' })
|
||||
isBuyer: boolean = false
|
||||
@@ -17,8 +19,8 @@ export class Review extends BaseEntity {
|
||||
@ManyToOne(() => Food)
|
||||
food: Food;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user: User;
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
user?: User;
|
||||
|
||||
@Property({ type: 'text', nullable: true })
|
||||
comment?: string;
|
||||
@@ -35,4 +37,10 @@ export class Review extends BaseEntity {
|
||||
@Enum(() => ReviewStatus)
|
||||
@Property({ type: 'string', default: ReviewStatus.PENDING })
|
||||
status: ReviewStatus = ReviewStatus.PENDING;
|
||||
|
||||
@ManyToOne(() => Review, { nullable: true })
|
||||
parent?: Review;
|
||||
|
||||
@ManyToOne(() => Admin, { nullable: true })
|
||||
admin?: Admin;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export class FoodRatingCronService {
|
||||
food: { id: food.id },
|
||||
status: ReviewStatus.APPROVED,
|
||||
deletedAt: null,
|
||||
parent: null,
|
||||
},
|
||||
{ fields: ['rating'] },
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { UpdateReviewDto } from '../dto/update-review.dto';
|
||||
import { ReplyReviewDto } from '../dto/reply-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 { RequiredEntityData, wrap } 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';
|
||||
@@ -16,6 +17,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { ReviewCreatedEvent } from '../events/review.events';
|
||||
import { sanitizeText } from '../../utils/sanitize.util';
|
||||
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
|
||||
import { Admin } from '../../admin/entities/admin.entity';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class ReviewService {
|
||||
@@ -83,7 +86,7 @@ export class ReviewService {
|
||||
return review;
|
||||
}
|
||||
|
||||
async findAll(dto: FindReviewsDto) {
|
||||
async findAll(dto: FindReviewsDto & { topLevel?: boolean }) {
|
||||
return this.reviewRepository.findAllPaginated(dto);
|
||||
}
|
||||
|
||||
@@ -96,15 +99,92 @@ export class ReviewService {
|
||||
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
|
||||
}
|
||||
|
||||
async findById(id: string, restaurantId: string): Promise<Review> {
|
||||
const review = await this.reviewRepository.findOne(
|
||||
{ id, food: { restaurant: { id: restaurantId } } },
|
||||
{ populate: ['food', 'user'] },
|
||||
async findWithReplies(reviewId: string, restaurantId: string): Promise<Review[]> {
|
||||
const reviews = await this.reviewRepository.find(
|
||||
{
|
||||
$and: [
|
||||
{
|
||||
$or: [
|
||||
{ id: reviewId },
|
||||
{ parent: { id: reviewId } }
|
||||
]
|
||||
},
|
||||
{ food: { restaurant: { id: restaurantId } } },
|
||||
|
||||
]
|
||||
},
|
||||
{ populate: ['food', 'user', 'admin'] },
|
||||
);
|
||||
if (!review) {
|
||||
if (!reviews) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
return review;
|
||||
|
||||
return reviews;
|
||||
}
|
||||
|
||||
async reply(reviewId: string, restaurantId: string, adminId: string, dto: ReplyReviewDto): Promise<Review> {
|
||||
const parent = await this.reviewRepository.findOne(
|
||||
{ id: reviewId, food: { restaurant: { id: restaurantId } }, parent: null },
|
||||
{ populate: ['food'] },
|
||||
);
|
||||
if (!parent) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const comment = sanitizeText(dto.comment);
|
||||
if (!comment) {
|
||||
throw new BadRequestException('متن پاسخ الزامی است');
|
||||
}
|
||||
|
||||
const existingReply = await this.reviewRepository.findOne({
|
||||
parent: reviewId,
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const admin = this.em.getReference(Admin, adminId);
|
||||
|
||||
if (existingReply) {
|
||||
existingReply.comment = comment;
|
||||
existingReply.status = ReviewStatus.APPROVED;
|
||||
existingReply.admin = admin;
|
||||
await this.em.persistAndFlush(existingReply);
|
||||
return existingReply;
|
||||
}
|
||||
|
||||
const reply = this.reviewRepository.create({
|
||||
isBuyer: false,
|
||||
food: parent.food,
|
||||
comment,
|
||||
rating: 0,
|
||||
status: ReviewStatus.APPROVED,
|
||||
parent: parent.id,
|
||||
admin,
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(reply);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async removeReply(reviewId: string, restaurantId: string): Promise<void> {
|
||||
const parent = await this.reviewRepository.findOne({
|
||||
id: reviewId,
|
||||
food: { restaurant: { id: restaurantId } },
|
||||
parent: null,
|
||||
});
|
||||
if (!parent) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const reply = await this.reviewRepository.findOne({
|
||||
parent: reviewId,
|
||||
deletedAt: null,
|
||||
});
|
||||
if (!reply) {
|
||||
throw new NotFoundException(ReviewMessage.REPLY_NOT_FOUND);
|
||||
}
|
||||
|
||||
reply.deletedAt = new Date();
|
||||
await this.em.persistAndFlush(reply);
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateReviewDto, isAdmin: boolean = false): Promise<Review> {
|
||||
@@ -114,7 +194,7 @@ export class ReviewService {
|
||||
}
|
||||
|
||||
// Only allow user to update their own reviews (unless admin)
|
||||
if (!isAdmin && review.user.id !== userId) {
|
||||
if (!isAdmin && review.user?.id !== userId) {
|
||||
throw new BadRequestException(ReviewMessage.CAN_ONLY_UPDATE_OWN);
|
||||
}
|
||||
|
||||
@@ -159,21 +239,24 @@ export class ReviewService {
|
||||
}
|
||||
|
||||
// Only allow user to delete their own reviews (unless admin)
|
||||
if (!isAdmin && review.user.id !== userId) {
|
||||
if (!isAdmin && review.user?.id !== userId) {
|
||||
throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
|
||||
}
|
||||
|
||||
const foodId = review.food.id;
|
||||
const isTopLevel = !review.parent;
|
||||
review.deletedAt = new Date();
|
||||
await this.em.persistAndFlush(review);
|
||||
|
||||
// Update food rating average
|
||||
await this.updateFoodRating(foodId);
|
||||
// Update food rating average only for top-level reviews
|
||||
if (isTopLevel) {
|
||||
await this.updateFoodRating(foodId);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateFoodRating(foodId: string): Promise<void> {
|
||||
const reviews = await this.reviewRepository.find(
|
||||
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null },
|
||||
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null, parent: null },
|
||||
{ fields: ['rating'] },
|
||||
);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ type FindReviewsOpts = {
|
||||
orderBy?: string;
|
||||
restId?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
parentId?: string;
|
||||
topLevel?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -27,11 +29,20 @@ export class ReviewRepository extends EntityRepository<Review> {
|
||||
* Supports: foodId, userId, status, ordering.
|
||||
*/
|
||||
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
|
||||
const { page = 1, limit = 10, foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
|
||||
const { page = 1, limit = 10, parentId, topLevel,
|
||||
foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Only list top-level reviews; replies are nested under their parent
|
||||
const where: FilterQuery<Review> = {};
|
||||
if (topLevel) {
|
||||
where.parent = null;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
where.parent = { id: parentId };
|
||||
}
|
||||
|
||||
if (foodId) {
|
||||
where.food = { id: foodId };
|
||||
@@ -53,7 +64,7 @@ export class ReviewRepository extends EntityRepository<Review> {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['food', 'user'],
|
||||
populate: ['user'],
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
Reference in New Issue
Block a user