This commit is contained in:
2026-07-17 10:18:39 +03:30
parent 4abc2ae6e5
commit 443b62eb8e
10 changed files with 214 additions and 20 deletions
@@ -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";`);
}
}
+5
View File
@@ -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;
}
+11 -3
View File
@@ -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'] },
);
+96 -13
View File
@@ -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);