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_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;
|
||||||
|
}
|
||||||
@@ -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'] },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
if (isTopLevel) {
|
||||||
await this.updateFoodRating(foodId);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user