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_CREATED = 'نظر با موفقیت ثبت شد',
COMMENT_UPDATED = 'نظر با موفقیت به‌روزرسانی شد', COMMENT_UPDATED = 'نظر با موفقیت به‌روزرسانی شد',
COMMENT_DELETED = 'نظر با موفقیت حذف شد', COMMENT_DELETED = 'نظر با موفقیت حذف شد',
CANNOT_REPLY_TO_REPLY = 'امکان پاسخ به پاسخ وجود ندارد',
REPLY_CREATED = 'پاسخ با موفقیت ثبت شد',
REPLY_UPDATED = 'پاسخ با موفقیت به‌روزرسانی شد',
REPLY_DELETED = 'پاسخ با موفقیت حذف شد',
REPLY_NOT_FOUND = 'پاسخی برای این نظر یافت نشد',
} }
export const enum IconMessage { export const enum IconMessage {
@@ -4,6 +4,7 @@ import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-review.dto'; import { UpdateReviewDto } from '../dto/update-review.dto';
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto'; import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
import { ChangeStatusDto } from '../dto/change-status.dto'; import { ChangeStatusDto } from '../dto/change-status.dto';
import { ReplyReviewDto } from '../dto/reply-review.dto';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -18,6 +19,7 @@ import {
import { AuthGuard } from 'src/modules/auth/guards/auth.guard'; import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { UserId } from 'src/common/decorators/user-id.decorator'; 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 { RestId } from 'src/common/decorators/rest-id.decorator';
import { ReviewStatus } from '../enums/review-status.enum'; import { ReviewStatus } from '../enums/review-status.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator'; import { Permissions } from 'src/common/decorators/permissions.decorator';
@@ -114,7 +116,7 @@ export class ReviewController {
@ApiQuery({ name: 'orderBy', required: false, type: String }) @ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] }) @ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
findAllAdmin(@Query() dto: FindReviewsDto, @RestId() restId: string) { 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) @UseGuards(AdminAuthGuard)
@@ -135,7 +137,7 @@ export class ReviewController {
@ApiOperation({ summary: 'review detail' }) @ApiOperation({ summary: 'review detail' })
@ApiParam({ name: 'id', required: true }) @ApiParam({ name: 'id', required: true })
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) { reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
return this.reviewService.findById(reviewId, restaurantId); return this.reviewService.findWithReplies(reviewId, restaurantId);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@@ -153,6 +155,33 @@ export class ReviewController {
return this.reviewService.changeStatus(reviewId, changeStatusDto.status, restaurantId); 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) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_REVIEWS) @Permissions(Permission.MANAGE_REVIEWS)
@ApiBearerAuth() @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 { BaseEntity } from '../../../common/entities/base.entity';
import { Food } from '../../foods/entities/food.entity'; import { Food } from '../../foods/entities/food.entity';
import { User } from '../../users/entities/user.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 { PositivePoint, NegativePoint } from '../enums/review-point.enum';
import { ReviewStatus } from '../enums/review-status.enum'; import { ReviewStatus } from '../enums/review-status.enum';
@@ -10,6 +10,8 @@ import { ReviewStatus } from '../enums/review-status.enum';
@Unique({ properties: ['food', 'user'] }) @Unique({ properties: ['food', 'user'] })
@Index({ properties: ['food', 'status'] }) @Index({ properties: ['food', 'status'] })
@Index({ properties: ['user'] }) @Index({ properties: ['user'] })
@Index({ properties: ['parent'] })
@Index({ properties: ['admin'] })
export class Review extends BaseEntity { export class Review extends BaseEntity {
@Property({ type: 'boolean' }) @Property({ type: 'boolean' })
isBuyer: boolean = false isBuyer: boolean = false
@@ -17,8 +19,8 @@ export class Review extends BaseEntity {
@ManyToOne(() => Food) @ManyToOne(() => Food)
food: Food; food: Food;
@ManyToOne(() => User) @ManyToOne(() => User, { nullable: true })
user: User; user?: User;
@Property({ type: 'text', nullable: true }) @Property({ type: 'text', nullable: true })
comment?: string; comment?: string;
@@ -35,4 +37,10 @@ export class Review extends BaseEntity {
@Enum(() => ReviewStatus) @Enum(() => ReviewStatus)
@Property({ type: 'string', default: ReviewStatus.PENDING }) @Property({ type: 'string', default: ReviewStatus.PENDING })
status: ReviewStatus = 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 }, food: { id: food.id },
status: ReviewStatus.APPROVED, status: ReviewStatus.APPROVED,
deletedAt: null, deletedAt: null,
parent: null,
}, },
{ fields: ['rating'] }, { fields: ['rating'] },
); );
+96 -13
View File
@@ -1,12 +1,13 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { CreateReviewDto } from '../dto/create-review.dto'; import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-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 { FindRestuarantReviewsDto, FindReviewsDto } from '../dto/find-reviews.dto';
import { ReviewRepository } from '../repositories/review.repository'; import { ReviewRepository } from '../repositories/review.repository';
import { FoodRepository } from '../../foods/repositories/food.repository'; import { FoodRepository } from '../../foods/repositories/food.repository';
import { UserRepository } from '../../users/repositories/user.repository'; import { UserRepository } from '../../users/repositories/user.repository';
import { EntityManager } from '@mikro-orm/postgresql'; 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 { Review } from '../entities/review.entity';
import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum'; import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
import { Order } from '../../orders/entities/order.entity'; import { Order } from '../../orders/entities/order.entity';
@@ -16,6 +17,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { ReviewCreatedEvent } from '../events/review.events'; import { ReviewCreatedEvent } from '../events/review.events';
import { sanitizeText } from '../../utils/sanitize.util'; import { sanitizeText } from '../../utils/sanitize.util';
import { OrderStatus } from 'src/modules/orders/interface/order.interface'; import { OrderStatus } from 'src/modules/orders/interface/order.interface';
import { Admin } from '../../admin/entities/admin.entity';
@Injectable() @Injectable()
export class ReviewService { export class ReviewService {
@@ -83,7 +86,7 @@ export class ReviewService {
return review; return review;
} }
async findAll(dto: FindReviewsDto) { async findAll(dto: FindReviewsDto & { topLevel?: boolean }) {
return this.reviewRepository.findAllPaginated(dto); return this.reviewRepository.findAllPaginated(dto);
} }
@@ -96,15 +99,92 @@ export class ReviewService {
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id }); return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
} }
async findById(id: string, restaurantId: string): Promise<Review> { async findWithReplies(reviewId: string, restaurantId: string): Promise<Review[]> {
const review = await this.reviewRepository.findOne( const reviews = await this.reviewRepository.find(
{ id, food: { restaurant: { id: restaurantId } } }, {
{ populate: ['food', 'user'] }, $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); 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> { 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) // 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); 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) // 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); throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
} }
const foodId = review.food.id; const foodId = review.food.id;
const isTopLevel = !review.parent;
review.deletedAt = new Date(); review.deletedAt = new Date();
await this.em.persistAndFlush(review); await this.em.persistAndFlush(review);
// Update food rating average // Update food rating average only for top-level reviews
await this.updateFoodRating(foodId); if (isTopLevel) {
await this.updateFoodRating(foodId);
}
} }
private async updateFoodRating(foodId: string): Promise<void> { private async updateFoodRating(foodId: string): Promise<void> {
const reviews = await this.reviewRepository.find( 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'] }, { fields: ['rating'] },
); );
@@ -14,6 +14,8 @@ type FindReviewsOpts = {
orderBy?: string; orderBy?: string;
restId?: string; restId?: string;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
parentId?: string;
topLevel?: boolean;
}; };
@Injectable() @Injectable()
@@ -27,11 +29,20 @@ export class ReviewRepository extends EntityRepository<Review> {
* Supports: foodId, userId, status, ordering. * Supports: foodId, userId, status, ordering.
*/ */
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> { 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; const offset = (page - 1) * limit;
// Only list top-level reviews; replies are nested under their parent
const where: FilterQuery<Review> = {}; const where: FilterQuery<Review> = {};
if (topLevel) {
where.parent = null;
}
if (parentId) {
where.parent = { id: parentId };
}
if (foodId) { if (foodId) {
where.food = { id: foodId }; where.food = { id: foodId };
@@ -53,7 +64,7 @@ export class ReviewRepository extends EntityRepository<Review> {
limit, limit,
offset, offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['food', 'user'], populate: ['user'],
}); });
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);