diff --git a/database/migrations/Migration20260716120000_addReviewsParentId.ts b/database/migrations/Migration20260716120000_addReviewsParentId.ts new file mode 100644 index 0000000..cb93cc9 --- /dev/null +++ b/database/migrations/Migration20260716120000_addReviewsParentId.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260716120000_addReviewsParentId extends Migration { + + override async up(): Promise { + 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 { + 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";`); + } + +} diff --git a/database/migrations/Migration20260716123000_nullableReviewUserForReplies.ts b/database/migrations/Migration20260716123000_nullableReviewUserForReplies.ts new file mode 100644 index 0000000..89a1bfe --- /dev/null +++ b/database/migrations/Migration20260716123000_nullableReviewUserForReplies.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260716123000_nullableReviewUserForReplies extends Migration { + + override async up(): Promise { + this.addSql(`alter table "reviews" alter column "user_id" drop not null;`); + } + + override async down(): Promise { + this.addSql(`alter table "reviews" alter column "user_id" set not null;`); + } + +} diff --git a/database/migrations/Migration20260717100000_addReviewsAdminId.ts b/database/migrations/Migration20260717100000_addReviewsAdminId.ts new file mode 100644 index 0000000..b9e8e28 --- /dev/null +++ b/database/migrations/Migration20260717100000_addReviewsAdminId.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260717100000_addReviewsAdminId extends Migration { + + override async up(): Promise { + 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 { + 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";`); + } + +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5bd9394..364c07c 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -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 { diff --git a/src/modules/review/controllers/review.controller.ts b/src/modules/review/controllers/review.controller.ts index 36ad8fc..b1468fe 100644 --- a/src/modules/review/controllers/review.controller.ts +++ b/src/modules/review/controllers/review.controller.ts @@ -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() diff --git a/src/modules/review/dto/reply-review.dto.ts b/src/modules/review/dto/reply-review.dto.ts new file mode 100644 index 0000000..0b316d4 --- /dev/null +++ b/src/modules/review/dto/reply-review.dto.ts @@ -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; +} diff --git a/src/modules/review/entities/review.entity.ts b/src/modules/review/entities/review.entity.ts index 1ec6e69..3ada7bc 100644 --- a/src/modules/review/entities/review.entity.ts +++ b/src/modules/review/entities/review.entity.ts @@ -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; } diff --git a/src/modules/review/providers/food-rating-cron.service.ts b/src/modules/review/providers/food-rating-cron.service.ts index e28543f..534bc3f 100644 --- a/src/modules/review/providers/food-rating-cron.service.ts +++ b/src/modules/review/providers/food-rating-cron.service.ts @@ -46,6 +46,7 @@ export class FoodRatingCronService { food: { id: food.id }, status: ReviewStatus.APPROVED, deletedAt: null, + parent: null, }, { fields: ['rating'] }, ); diff --git a/src/modules/review/providers/review.service.ts b/src/modules/review/providers/review.service.ts index 724efec..e2c96e5 100644 --- a/src/modules/review/providers/review.service.ts +++ b/src/modules/review/providers/review.service.ts @@ -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 { - const review = await this.reviewRepository.findOne( - { id, food: { restaurant: { id: restaurantId } } }, - { populate: ['food', 'user'] }, + async findWithReplies(reviewId: string, restaurantId: string): Promise { + 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 { + 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 { + 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 { @@ -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 { 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'] }, ); diff --git a/src/modules/review/repositories/review.repository.ts b/src/modules/review/repositories/review.repository.ts index 5114e1e..7ee69b6 100644 --- a/src/modules/review/repositories/review.repository.ts +++ b/src/modules/review/repositories/review.repository.ts @@ -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 { * Supports: foodId, userId, status, ordering. */ async findAllPaginated(opts: FindReviewsOpts = {}): Promise> { - 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 = {}; + 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 { limit, offset, orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, - populate: ['food', 'user'], + populate: ['user'], }); const totalPages = Math.ceil(total / limit);